@amxts/core 0.1.0 → 0.2.1
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/README.md +21 -25
- package/README.ru.md +22 -24
- package/as/amxts.d.ts +63 -3
- package/as/constants.ts +684 -116
- package/as/entities.ts +1905 -1364
- package/as/events.ts +1696 -907
- package/as/facade.ts +1240 -220
- package/as/fetch.ts +1519 -0
- package/as/flags.ts +159 -145
- package/as/hlds.ts +1071 -0
- package/as/hooks.ts +5226 -5379
- package/as/kit.ts +15 -5
- package/as/lib/check.ts +1 -1
- package/as/natives.ts +2658 -2499
- package/as/remote.ts +39 -15
- package/as/tsconfig.json +7 -2
- package/as/vector.ts +12 -9
- package/includes/order.txt +22 -29
- package/includes/sources.json +0 -8
- package/node_modules/assemblyscript/std/assembly/index.d.ts +20 -11
- package/package.json +29 -7
- package/runtime/deps/assemblyscript/dist/asc.js +86 -16
- package/runtime/deps/assemblyscript/dist/assemblyscript.js +29 -29
- package/runtime/licenses/MPL-2.0.txt +373 -0
- package/runtime/licenses/README.md +5 -1
- package/runtime/licenses/curl-COPYING +22 -0
- package/runtime/licenses/libssh2-COPYING +43 -0
- package/runtime/licenses/mbedTLS-LICENSE +553 -0
- package/runtime/natives.txt +108 -105
- package/runtime/patches/assemblyscript-0.28.20-amxts.patch +752 -296
- package/runtime/patches/wamr-2.4.5-amxts.patch +148 -0
- package/scripts/apply-docs.ts +2 -2
- package/scripts/auto-imports.ts +2 -2
- package/scripts/build-identity.ts +115 -0
- package/scripts/build-wasm.ts +101 -50
- package/scripts/check.ts +17 -3
- package/scripts/client-messages.ts +246 -0
- package/scripts/compile-cache.ts +4 -2
- package/scripts/compile-pool.ts +128 -0
- package/scripts/compile-worker.ts +23 -0
- package/scripts/compile.ts +156 -59
- package/scripts/docker-server.ts +13 -3
- package/scripts/docs/as/amxts.ts +86 -0
- package/scripts/docs/as/facade.ts +688 -188
- package/scripts/docs/as/fetch.ts +805 -0
- package/scripts/docs/entities.ts +43 -144
- package/scripts/docs/events.ts +17 -28
- package/scripts/docs/game.ts +68 -22
- package/scripts/docs/messages.ts +330 -16
- package/scripts/editor-api.ts +101 -0
- package/scripts/hlds-events.ts +370 -0
- package/scripts/includes.ts +12 -10
- package/scripts/player-fields.ts +16 -15
- package/scripts/prebuilt.ts +65 -23
- package/scripts/prepare.ts +42 -28
- package/scripts/project.ts +120 -20
- package/scripts/reapi-events.ts +98 -0
- package/scripts/shared-modules.ts +85 -23
- package/scripts/source-map.ts +242 -0
- package/scripts/system.ts +1 -1
- package/scripts/typed-commands.ts +263 -0
- package/scripts/typed-configs.ts +23 -12
- package/scripts/ui.ts +3 -1
- package/scripts/upgrade-menus.ts +274 -0
- package/scripts/upgrade-names.ts +384 -0
- package/scripts/upgrade.ts +857 -0
- package/src/cli-api.mjs +37 -4
- package/src/system.mjs +74 -3
- package/src/testing/compile.ts +8 -5
- package/src/testing/index.ts +2 -1
- package/src/testing/natives.ts +199 -5
- package/src/testing/network.ts +531 -0
- package/src/testing/server.ts +572 -48
- package/src/testing/setup.ts +1 -1
- package/src/testing/tables.ts +25 -0
- package/as/modules/http.ts +0 -184
- package/scripts/docs/as/modules/http.ts +0 -66
|
@@ -41,13 +41,23 @@
|
|
|
41
41
|
// The types come from compiling the module on its own with asc: its exported
|
|
42
42
|
// functions are compiled as exports, so every result type the compiler
|
|
43
43
|
// inferred is known, and so is every field. That compile is done before the
|
|
44
|
-
// plugin's (asc is not reentrant)
|
|
45
|
-
|
|
44
|
+
// plugin's (asc is not reentrant). What a plugin's compile needs of it - the
|
|
45
|
+
// proxy and the dispatcher, its surface - is remembered while the files it
|
|
46
|
+
// read are unchanged: in memory, and on disk in the project's
|
|
47
|
+
// node_modules/.cache/amxts/analysis, so another process - a build started
|
|
48
|
+
// again, a parallel compile - takes it from there. A module package from npm
|
|
49
|
+
// carries its surface beside its prebuilt .aot (scripts/prebuilt.ts), taken
|
|
50
|
+
// while the project would make the same one.
|
|
51
|
+
import type { Project, Sources } from './project';
|
|
52
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
53
|
+
import { fileURLToPath } from 'node:url';
|
|
46
54
|
// @ts-ignore - shipped as JavaScript, with types beside it we do not need here
|
|
47
55
|
import * as as from '../runtime/deps/assemblyscript/dist/assemblyscript.js';
|
|
48
56
|
import { ascMain } from './asc';
|
|
57
|
+
import { codeIdentity, diskCache } from './compile-cache';
|
|
49
58
|
import { playerFieldsBuild } from './player-fields';
|
|
50
|
-
import {
|
|
59
|
+
import { prebuiltSurface } from './prebuilt';
|
|
60
|
+
import { ascPath, currentProjectDir, sourcesFor } from './project';
|
|
51
61
|
import { existsSync, statSync } from './tracked-fs';
|
|
52
62
|
|
|
53
63
|
/** Where the owner's dispatcher is parsed: beside the plugin, like its natives' wrappers. */
|
|
@@ -141,28 +151,81 @@ export interface ModuleAnalysis {
|
|
|
141
151
|
hash: number;
|
|
142
152
|
}
|
|
143
153
|
|
|
144
|
-
|
|
154
|
+
/** What a plugin's compile needs of a module: the proxy, the owner's dispatcher, and whether functions cross. */
|
|
155
|
+
export interface ModuleSurface {
|
|
156
|
+
proxy: string;
|
|
157
|
+
serve: string;
|
|
158
|
+
callbacks: boolean;
|
|
159
|
+
}
|
|
145
160
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
161
|
+
export function surfaceOf(analysis: ModuleAnalysis): ModuleSurface {
|
|
162
|
+
return { proxy: proxySource(analysis), serve: serveSource(analysis), callbacks: analysis.fns.length > 0 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
166
|
+
|
|
167
|
+
/** The surfaces kept on disk, per project folder; none where the project has no node_modules (a server). */
|
|
168
|
+
const kept = new Map<string, ReturnType<typeof diskCache>>();
|
|
169
|
+
|
|
170
|
+
/** The current project's surfaces on disk. */
|
|
171
|
+
export function keptSurfaces() {
|
|
172
|
+
const dir = currentProjectDir();
|
|
173
|
+
const modules = join(dir, 'node_modules');
|
|
174
|
+
if (!kept.has(dir)) kept.set(dir, diskCache(existsSync(modules) ? join(modules, '.cache', 'amxts', 'analysis') : null, () => codeIdentity([join(HERE, 'shared-modules.ts')])));
|
|
175
|
+
return kept.get(dir)!;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The places a module reaches, as files on disk with their times: it is the
|
|
180
|
+
* same module while they are. A file that takes another's place changes it.
|
|
181
|
+
*/
|
|
182
|
+
function stampOf(sources: Sources, places: string[]): string {
|
|
183
|
+
return places.map((place) => {
|
|
152
184
|
const file = sources.real(place);
|
|
153
185
|
if (!file) return place;
|
|
154
186
|
const info = statSync(file);
|
|
155
187
|
return `${file}:${info.mtimeMs}:${info.size}`;
|
|
156
188
|
}).join('|');
|
|
157
|
-
const known = cache.get(path);
|
|
158
|
-
if (known && known.stamp === stamp) return known.analysis;
|
|
159
|
-
const analysis = analyze(root, name);
|
|
160
|
-
cache.set(path, { stamp, analysis });
|
|
161
|
-
analysis.catch(() => cache.delete(path));
|
|
162
|
-
return analysis;
|
|
163
189
|
}
|
|
164
190
|
|
|
165
|
-
|
|
191
|
+
/** The surfaces made in this process, by project - its config gives the modules their options - and module file. */
|
|
192
|
+
const surfaces = new WeakMap<Project, Map<string, { places: string[]; stamp: string; surface: Promise<ModuleSurface> }>>();
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The module's surface: the one its package came with, else one kept on
|
|
196
|
+
* disk, else made from its analysis. Remembered while the files it reaches -
|
|
197
|
+
* the module and everything it imports: a field added to a facade class
|
|
198
|
+
* changes what crosses - are unchanged.
|
|
199
|
+
*/
|
|
200
|
+
export function moduleSurface(root: string, name: string): Promise<ModuleSurface> {
|
|
201
|
+
const path = join(root, 'modules', `${name}.ts`);
|
|
202
|
+
const sources = sourcesFor(root);
|
|
203
|
+
if (!surfaces.has(sources.project)) surfaces.set(sources.project, new Map());
|
|
204
|
+
const made = surfaces.get(sources.project)!;
|
|
205
|
+
const known = made.get(path);
|
|
206
|
+
if (known && known.stamp === stampOf(sources, known.places)) return known.surface;
|
|
207
|
+
|
|
208
|
+
const places = [...sources.reach(path)];
|
|
209
|
+
const pkg = sources.project.modules.find(each => each.short === name && each.module === sources.real(path));
|
|
210
|
+
const shipped = pkg ? prebuiltSurface(pkg, sources) : null;
|
|
211
|
+
const surface = shipped && 'surface' in shipped
|
|
212
|
+
? Promise.resolve(shipped.surface)
|
|
213
|
+
: keptSurfaces().cached(['surface', resolve(root), name], async () => surfaceOf(await analyzeModule(root, name)));
|
|
214
|
+
made.set(path, { places, stamp: stampOf(sources, places), surface });
|
|
215
|
+
surface.catch(() => made.delete(path));
|
|
216
|
+
return surface;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The module's exports, as the compiler sees them. */
|
|
220
|
+
export async function analyzeModule(root: string, name: string): Promise<ModuleAnalysis> {
|
|
221
|
+
return describe(await compileAlone(root, name), name);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Compiles `~/modules/<name>` on its own, its exported functions as exports:
|
|
226
|
+
* the compiler's program, or an error with what asc said.
|
|
227
|
+
*/
|
|
228
|
+
export async function compileAlone(root: string, name: string): Promise<any> {
|
|
166
229
|
let program: any = null;
|
|
167
230
|
const playerFields = playerFieldsBuild();
|
|
168
231
|
const sources = sourcesFor(root);
|
|
@@ -179,8 +242,7 @@ async function analyze(root: string, name: string): Promise<ModuleAnalysis> {
|
|
|
179
242
|
transforms: [playerFields.transform, class { afterCompile() { program = (this as any).program; } }],
|
|
180
243
|
});
|
|
181
244
|
if (error || !program) throw new Error(`~/modules/${name} does not compile:\n${stderr.toString() || String(error?.message ?? error)}`);
|
|
182
|
-
|
|
183
|
-
return describe(program, name);
|
|
245
|
+
return program;
|
|
184
246
|
}
|
|
185
247
|
|
|
186
248
|
function describe(program: any, name: string): ModuleAnalysis {
|
|
@@ -878,10 +940,10 @@ export async function sharedModulesBuild(root: string, entry: string) {
|
|
|
878
940
|
for (const path of sourcesFor(root).reach(join(root, entry))) {
|
|
879
941
|
const name = moduleName(root, path);
|
|
880
942
|
if (!name || !hasOwner(root, name)) continue;
|
|
881
|
-
const
|
|
882
|
-
if (
|
|
883
|
-
if (name === owned) serve =
|
|
884
|
-
else proxies.set(resolve(path),
|
|
943
|
+
const surface = await moduleSurface(root, name);
|
|
944
|
+
if (surface.callbacks) callbacks = true;
|
|
945
|
+
if (name === owned) serve = surface.serve;
|
|
946
|
+
else proxies.set(resolve(path), surface.proxy);
|
|
885
947
|
}
|
|
886
948
|
|
|
887
949
|
return {
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// Where a plugin's machine code came from in its TypeScript, carried inside
|
|
2
|
+
// the plugin: what the module reads when a call fails, or when a plugin asks
|
|
3
|
+
// for `error.stack`, to print the stack as functions, files and lines
|
|
4
|
+
// (runtime/src/module.cpp, PrintStack).
|
|
5
|
+
//
|
|
6
|
+
// asc writes a source map of the wasm it emits: for a wasm, a mapping's
|
|
7
|
+
// column is a byte offset into the binary, and WAMR's frames carry the same
|
|
8
|
+
// offset - the opcode a frame stopped at (wamrc --enable-dump-call-stack).
|
|
9
|
+
// The table goes into the wasm as a custom section, appended after the code
|
|
10
|
+
// so no offset moves, and wamrc copies it into the .aot
|
|
11
|
+
// (--emit-custom-sections): a plugin carries its map wherever its .aot goes.
|
|
12
|
+
//
|
|
13
|
+
// The section's text, line by line:
|
|
14
|
+
//
|
|
15
|
+
// amxts-map 1
|
|
16
|
+
// root <folder> the project's folder, absolute, for a dev build:
|
|
17
|
+
// where the module looks for the files first
|
|
18
|
+
// files <count> then one file a line, as the project names it
|
|
19
|
+
// functions <first> <count> the wasm index of the first defined function,
|
|
20
|
+
// then one name a line, by index; an empty name is
|
|
21
|
+
// the hood's own frame, which the stack leaves out
|
|
22
|
+
// mappings then the source map's own mappings, one line
|
|
23
|
+
//
|
|
24
|
+
// The mappings stay as the source map writes them - base64 VLQ, each segment
|
|
25
|
+
// a byte offset and, when the code there has a place, its file, line and
|
|
26
|
+
// column - which is as small as the table gets without losing a column.
|
|
27
|
+
|
|
28
|
+
/** The custom section's name. */
|
|
29
|
+
export const MAP_SECTION = 'amxts.map';
|
|
30
|
+
|
|
31
|
+
/** A place in the TypeScript: 1-based line and column. */
|
|
32
|
+
export interface Place {
|
|
33
|
+
file: string;
|
|
34
|
+
line: number;
|
|
35
|
+
column: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One mapping: from its byte offset on, the code is at `place` - null in a
|
|
40
|
+
* file the map leaves out, undefined where the source map gave no place.
|
|
41
|
+
*/
|
|
42
|
+
interface Mapping {
|
|
43
|
+
offset: number;
|
|
44
|
+
place?: Place | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A plugin's map, read back. */
|
|
48
|
+
export interface PluginMap {
|
|
49
|
+
/** The project's folder for a dev build, else empty: the files are relative to it. */
|
|
50
|
+
root: string;
|
|
51
|
+
files: string[];
|
|
52
|
+
first: number;
|
|
53
|
+
functions: string[];
|
|
54
|
+
mappings: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
58
|
+
|
|
59
|
+
/** Source map mappings, decoded - a wasm's are all on its one line - with no place in a file named null. */
|
|
60
|
+
function decode(mappings: string, files: (string | null)[]): Mapping[] {
|
|
61
|
+
const out: Mapping[] = [];
|
|
62
|
+
const state = [0, 0, 0, 0];
|
|
63
|
+
for (const segment of mappings.split(/[,;]/)) {
|
|
64
|
+
if (!segment) continue;
|
|
65
|
+
const fields: number[] = [];
|
|
66
|
+
let value = 0;
|
|
67
|
+
let shift = 0;
|
|
68
|
+
for (const char of segment) {
|
|
69
|
+
const digit = BASE64.indexOf(char);
|
|
70
|
+
value += (digit & 31) << shift;
|
|
71
|
+
if (digit & 32) {
|
|
72
|
+
shift += 5;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
fields.push(value & 1 ? -(value >>> 1) : value >>> 1);
|
|
76
|
+
value = 0;
|
|
77
|
+
shift = 0;
|
|
78
|
+
}
|
|
79
|
+
fields.forEach((field, i) => (state[i] += field));
|
|
80
|
+
const file = fields.length >= 4 ? files[state[1]] : undefined;
|
|
81
|
+
out.push({ offset: state[0], place: file ? { file, line: state[2] + 1, column: state[3] + 1 } : (file === null ? null : undefined) });
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `mappings` as a source map writes them, and the files they name. */
|
|
87
|
+
function encode(mappings: Mapping[]): { files: string[]; mappings: string } {
|
|
88
|
+
const vlq = (number: number) => {
|
|
89
|
+
let value = number < 0 ? (-number << 1) | 1 : number << 1;
|
|
90
|
+
let out = '';
|
|
91
|
+
do {
|
|
92
|
+
const digit = value & 31;
|
|
93
|
+
value >>>= 5;
|
|
94
|
+
out += BASE64[value ? digit | 32 : digit];
|
|
95
|
+
} while (value);
|
|
96
|
+
return out;
|
|
97
|
+
};
|
|
98
|
+
const files: string[] = [];
|
|
99
|
+
const state = [0, 0, 0, 0];
|
|
100
|
+
const segments = mappings.map(({ offset, place }) => {
|
|
101
|
+
const fields = [offset];
|
|
102
|
+
if (place) {
|
|
103
|
+
if (!files.includes(place.file)) files.push(place.file);
|
|
104
|
+
fields.push(files.indexOf(place.file), place.line - 1, place.column - 1);
|
|
105
|
+
}
|
|
106
|
+
return fields.map((field, i) => {
|
|
107
|
+
const delta = field - state[i];
|
|
108
|
+
state[i] = field;
|
|
109
|
+
return vlq(delta);
|
|
110
|
+
}).join('');
|
|
111
|
+
});
|
|
112
|
+
return { files, mappings: segments.join(',') };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A plugin's map from asc's source map: each file by `name` - null leaves it
|
|
117
|
+
* out, its code then has no place - and `functions`, by wasm index from
|
|
118
|
+
* `first`. Code the source map gives no place - a call the optimiser made
|
|
119
|
+
* anew, as often as not - is where the code before it is: a frame stops at
|
|
120
|
+
* such calls. A mapping that says what the one before it said goes.
|
|
121
|
+
*/
|
|
122
|
+
export function pluginMap(sourceMap: string, name: (file: string) => string | null, first: number, functions: string[], root = ''): PluginMap {
|
|
123
|
+
const { sources = [], mappings = '' } = sourceMap ? JSON.parse(sourceMap) : {};
|
|
124
|
+
const kept: Mapping[] = [];
|
|
125
|
+
let last = '';
|
|
126
|
+
for (const mapping of decode(mappings, sources.map(name))) {
|
|
127
|
+
if (mapping.place === undefined) continue;
|
|
128
|
+
const key = mapping.place ? `${mapping.place.file}:${mapping.place.line}:${mapping.place.column}` : '';
|
|
129
|
+
if (key === last) continue;
|
|
130
|
+
last = key;
|
|
131
|
+
kept.push(mapping);
|
|
132
|
+
}
|
|
133
|
+
return { root, ...encode(kept), first, functions };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Where the code at byte `offset` of the wasm came from, or null. */
|
|
137
|
+
export function placeOf(map: PluginMap, offset: number): Place | null {
|
|
138
|
+
let found: Place | null = null;
|
|
139
|
+
for (const mapping of decode(map.mappings, map.files)) {
|
|
140
|
+
if (mapping.offset > offset) break;
|
|
141
|
+
found = mapping.place ?? null;
|
|
142
|
+
}
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A function's name as a stack shows it, from its name in the wasm; empty
|
|
148
|
+
* for the library's error handling - `__throw`, the errors' constructors,
|
|
149
|
+
* what makes their stack - which a stack leaves out: the frame below is where
|
|
150
|
+
* the error was made.
|
|
151
|
+
*/
|
|
152
|
+
export function displayName(internal: string): string {
|
|
153
|
+
if (internal.startsWith('~lib/error/')) return '';
|
|
154
|
+
if (/^start:[^~]*$/.test(internal)) return '<top level>';
|
|
155
|
+
const name = internal
|
|
156
|
+
// a function made at the top level, as one made in a function: `onSelect~anonymous|0`
|
|
157
|
+
.replace(/^start:[^~]*~anonymous\|\d+/, '<anonymous>')
|
|
158
|
+
// asc writes a function type's parentheses as %28 and %29
|
|
159
|
+
.replace(/%([\dA-F]{2})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
|
|
160
|
+
// a path in front of a name, the generic arguments' too:
|
|
161
|
+
// `~lib/array/Array<plugins/shop/Item>#push` is `Array<Item>#push`
|
|
162
|
+
.replace(/(?:[\w.~@-]+\/)+/g, '')
|
|
163
|
+
.replace(/~anonymous\|\d+/g, '/<anonymous>')
|
|
164
|
+
.replace(/@\w+$/, '')
|
|
165
|
+
.replace(/#(?:get|set):/, '.');
|
|
166
|
+
return name.endsWith('#constructor') ? `new ${name.slice(0, -'#constructor'.length)}` : name.replace(/#/g, '.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The section's text for `map`. */
|
|
170
|
+
export function mapText(map: PluginMap): string {
|
|
171
|
+
return [
|
|
172
|
+
'amxts-map 1',
|
|
173
|
+
`root ${map.root}`,
|
|
174
|
+
`files ${map.files.length}`,
|
|
175
|
+
...map.files,
|
|
176
|
+
`functions ${map.first} ${map.functions.length}`,
|
|
177
|
+
...map.functions,
|
|
178
|
+
'mappings',
|
|
179
|
+
map.mappings,
|
|
180
|
+
'',
|
|
181
|
+
].join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `wasm` with `text` appended as the custom section `name`. */
|
|
185
|
+
export function withSection(wasm: Uint8Array, name: string, text: string): Uint8Array {
|
|
186
|
+
const leb = (value: number) => {
|
|
187
|
+
const bytes: number[] = [];
|
|
188
|
+
do {
|
|
189
|
+
const byte = value & 0x7F;
|
|
190
|
+
value >>>= 7;
|
|
191
|
+
bytes.push(value ? byte | 0x80 : byte);
|
|
192
|
+
} while (value);
|
|
193
|
+
return bytes;
|
|
194
|
+
};
|
|
195
|
+
const encoder = new TextEncoder();
|
|
196
|
+
const nameBytes = encoder.encode(name);
|
|
197
|
+
const content = [...leb(nameBytes.length), ...nameBytes, ...encoder.encode(text)];
|
|
198
|
+
return new Uint8Array([...wasm, 0, ...leb(content.length), ...content]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The map a wasm carries, or null. */
|
|
202
|
+
export function readMap(wasm: Uint8Array): PluginMap | null {
|
|
203
|
+
const text = readSection(wasm, MAP_SECTION);
|
|
204
|
+
return text === null ? null : parseMap(text);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The text of a wasm's custom section by its name, or null. */
|
|
208
|
+
export function readSection(wasm: Uint8Array, wanted: string): string | null {
|
|
209
|
+
let at = 8;
|
|
210
|
+
const u32 = () => {
|
|
211
|
+
let result = 0;
|
|
212
|
+
let shift = 0;
|
|
213
|
+
for (;;) {
|
|
214
|
+
const byte = wasm[at++];
|
|
215
|
+
result |= (byte & 0x7F) << shift;
|
|
216
|
+
if ((byte & 0x80) === 0) return result >>> 0;
|
|
217
|
+
shift += 7;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
while (at < wasm.length) {
|
|
221
|
+
const id = wasm[at++];
|
|
222
|
+
const size = u32();
|
|
223
|
+
const end = at + size;
|
|
224
|
+
if (id === 0) {
|
|
225
|
+
const length = u32();
|
|
226
|
+
const name = new TextDecoder().decode(wasm.subarray(at, at + length));
|
|
227
|
+
if (name === wanted) return new TextDecoder().decode(wasm.subarray(at + length, end));
|
|
228
|
+
}
|
|
229
|
+
at = end;
|
|
230
|
+
}
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The section's text, read back. */
|
|
235
|
+
export function parseMap(text: string): PluginMap {
|
|
236
|
+
const lines = text.split('\n');
|
|
237
|
+
const fileCount = Number(lines[2].split(' ')[1]);
|
|
238
|
+
const files = lines.slice(3, 3 + fileCount);
|
|
239
|
+
const [, first, count] = lines[3 + fileCount].split(' ').map(Number);
|
|
240
|
+
const at = 4 + fileCount;
|
|
241
|
+
return { root: lines[1].slice('root '.length), files, first, functions: lines.slice(at, at + count), mappings: lines[at + count + 1] };
|
|
242
|
+
}
|
package/scripts/system.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { executable, MODULE_FILE } from '../src/system.mjs';
|
|
8
8
|
|
|
9
9
|
export type { ServerSystem, System } from '../src/system.mjs';
|
|
10
|
-
export { describeSystem, detectServerSystem, executable, HOST_SYSTEM, MODULE_FILE, parseSystem, serverSystem, SYSTEM_NAME, SYSTEMS, TARGET_ABI, WAMRC_PACKAGE, wamrcPath } from '../src/system.mjs';
|
|
10
|
+
export { describeSystem, detectServerSystem, executable, HOST_SYSTEM, manifestName, MODULE_FILE, moduleVersion, parseSystem, serverFiles, serverFolder, serverImage, serverSystem, SYSTEM_NAME, SYSTEMS, TARGET_ABI, WAMRC_PACKAGE, wamrcPath } from '../src/system.mjs';
|
|
11
11
|
|
|
12
12
|
const CORE = resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
13
13
|
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Commands with typed arguments: `server.addCommand` and `addServerCommand`.
|
|
2
|
+
//
|
|
3
|
+
// interface KickArgs {
|
|
4
|
+
// target: Player;
|
|
5
|
+
// reason?: string;
|
|
6
|
+
// }
|
|
7
|
+
//
|
|
8
|
+
// server.addCommand<KickArgs>("/kick <target> [reason]", ({ player, target, reason }) => {
|
|
9
|
+
// target.kick(reason ?? "Kicked by admin");
|
|
10
|
+
// });
|
|
11
|
+
//
|
|
12
|
+
// The editor types the handler with amxts.d.ts's signatures: the type
|
|
13
|
+
// argument's fields and `player`; without one, text for every name the usage
|
|
14
|
+
// has. AssemblyScript has neither intersection types nor reflection, so the
|
|
15
|
+
// build writes, for each call - reading each file on its way to the compiler
|
|
16
|
+
// (Sources.read), after the typed configs:
|
|
17
|
+
//
|
|
18
|
+
// - the usage's names, `<name>` required and `[name]` optional, checked
|
|
19
|
+
// against the interface both ways: a name in one and not the other, an
|
|
20
|
+
// optional one in one and a required one in the other, a required one after
|
|
21
|
+
// an optional one, stop the build with the place and the fix;
|
|
22
|
+
// - a class of the handler's argument: `player` (a player's command) and the
|
|
23
|
+
// interface's fields, typed as the interface types them, which implements
|
|
24
|
+
// the interface, so the argument goes where the interface is expected;
|
|
25
|
+
// - a function that registers the command with a parser of its words, each
|
|
26
|
+
// read as its type says (`CommandWords` in the facade): a `number` parsed,
|
|
27
|
+
// a `Player` found by `#userid`, the whole name or a part of it, a union of
|
|
28
|
+
// string literals one of them, a `string` as it is, the last argument, when
|
|
29
|
+
// it is text, the rest of the line. A word that is not what the command
|
|
30
|
+
// takes answers the one who typed it with the usage, and the handler does
|
|
31
|
+
// not run;
|
|
32
|
+
// - the call's `server.addCommand<KickArgs>` becomes that function's name,
|
|
33
|
+
// padded, so the rest of the line keeps its columns.
|
|
34
|
+
//
|
|
35
|
+
// The interface is read from the source text - a syntax tree, no type checker
|
|
36
|
+
// - in this file or imported, as the typed configs read theirs.
|
|
37
|
+
import type { ImportReader } from './typed-configs';
|
|
38
|
+
import ts from 'typescript';
|
|
39
|
+
import { ShapeError, Shapes, withGenerated } from './typed-configs';
|
|
40
|
+
|
|
41
|
+
/** The facade by its place in the tree, as the files' imports have it once rewritten (scripts/project.ts). */
|
|
42
|
+
const FACADE = '~/facade';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A call's usage: a string in place; a template whose substitutions make the
|
|
46
|
+
* name, `${name} [value]`, its arguments the text after them; or, from any
|
|
47
|
+
* other expression, a name alone, made at run time, without arguments - a
|
|
48
|
+
* command it is given by a variable takes no words.
|
|
49
|
+
*/
|
|
50
|
+
function usageOf(node: ts.Expression, typed: boolean, file: ts.SourceFile): Usage {
|
|
51
|
+
if (ts.isStringLiteralLike(node)) return usageOrFail(parseUsage(node.text), node, file);
|
|
52
|
+
if (ts.isTemplateExpression(node)) {
|
|
53
|
+
const text = node.head.text + node.templateSpans.map(span => `$name${span.literal.text}`).join('');
|
|
54
|
+
return usageOrFail(parseUsage(text), node, file);
|
|
55
|
+
}
|
|
56
|
+
if (typed) throw new ShapeError(node, file, 'a command with arguments has its usage in place - "/kick <target>", or `${name} <target>` for a name made at run time: the build reads the arguments from it');
|
|
57
|
+
return { name: '', args: [] };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function usageOrFail(usage: Usage | string, node: ts.Node, file: ts.SourceFile): Usage {
|
|
61
|
+
if (typeof usage === 'string') throw new ShapeError(node, file, usage);
|
|
62
|
+
return usage;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What an argument is read as. */
|
|
66
|
+
type Kind = { kind: 'number' } | { kind: 'text' } | { kind: 'player' } | { kind: 'name'; names: string[] };
|
|
67
|
+
|
|
68
|
+
interface Argument {
|
|
69
|
+
name: string;
|
|
70
|
+
optional: boolean;
|
|
71
|
+
kind: Kind;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface Usage {
|
|
75
|
+
name: string;
|
|
76
|
+
args: { name: string; optional: boolean }[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface Place { path: string; file: ts.SourceFile }
|
|
80
|
+
|
|
81
|
+
/** A usage's command name and arguments, in order: `"/kick <target> [reason]"`; a chat phrase, `"say rules"`, has none. Text: what is wrong with it. */
|
|
82
|
+
export function parseUsage(usage: string): Usage | string {
|
|
83
|
+
const text = usage.trim();
|
|
84
|
+
if (text.startsWith('say ')) return { name: text, args: [] };
|
|
85
|
+
const [name, ...rest] = text.split(/\s+/);
|
|
86
|
+
if (!name) return 'the usage is empty: write the command\'s name first, e.g. "/hp"';
|
|
87
|
+
const args: Usage['args'] = [];
|
|
88
|
+
for (const word of rest) {
|
|
89
|
+
const match = word.match(/^(?:<([A-Z_$][\w$]*)>|\[([A-Z_$][\w$]*)\])$/i);
|
|
90
|
+
if (!match) return `"${word}" - an argument is one word, <name> or [name]: ${name} <target> [reason]`;
|
|
91
|
+
const optional = match[2] !== undefined;
|
|
92
|
+
const argName = match[1] ?? match[2];
|
|
93
|
+
if (!optional && args.some(arg => arg.optional)) return `<${argName}> - a required argument cannot follow an optional one`;
|
|
94
|
+
if (args.some(arg => arg.name === argName)) return `${argName} - the usage names it twice`;
|
|
95
|
+
args.push({ name: argName, optional });
|
|
96
|
+
}
|
|
97
|
+
return { name, args };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What one argument's type is read as. */
|
|
101
|
+
function kindOf(type: ts.TypeNode, shapes: Shapes, at: Place): Kind {
|
|
102
|
+
if (ts.isTypeReferenceNode(type) && ts.isIdentifier(type.typeName) && type.typeName.text === 'Player' && !type.typeArguments) return { kind: 'player' };
|
|
103
|
+
const shape = shapes.ofType(type, at);
|
|
104
|
+
if (shape.kind === 'number' || shape.kind === 'text') return { kind: shape.kind };
|
|
105
|
+
if (shape.kind === 'name') return { kind: 'name', names: shape.names };
|
|
106
|
+
throw new ShapeError(type, at.file, `${type.getText(at.file)} - a command's argument is a number, a string, a Player or a union of string literals`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The arguments a call's type says: each usage name with its interface field's type; text for each without a type. */
|
|
110
|
+
function argumentsOf(call: ts.CallExpression, usage: Usage, shapes: Shapes, at: Place, player: boolean): Argument[] {
|
|
111
|
+
const typeArgument = call.typeArguments?.[0];
|
|
112
|
+
if (!typeArgument) return usage.args.map(arg => ({ ...arg, kind: { kind: 'text' } }));
|
|
113
|
+
|
|
114
|
+
if (!ts.isTypeReferenceNode(typeArgument)) {
|
|
115
|
+
throw new ShapeError(typeArgument, at.file, `${typeArgument.getText(at.file)} - a command's arguments are an interface declared beside it, passed by its name: interface KickArgs { target: Player }, then addCommand<KickArgs>(...)`);
|
|
116
|
+
}
|
|
117
|
+
const found = shapes.declaration(typeArgument.typeName, at);
|
|
118
|
+
if (!found || !ts.isInterfaceDeclaration(found.node)) {
|
|
119
|
+
throw new ShapeError(typeArgument, at.file, `${typeArgument.getText(at.file)} - a command's arguments are an interface, declared in this file or imported into it`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fields = new Map<string, Argument & { node: ts.Node }>();
|
|
123
|
+
for (const member of found.node.members) {
|
|
124
|
+
if (!ts.isPropertySignature(member) || !member.type || !ts.isIdentifier(member.name)) {
|
|
125
|
+
throw new ShapeError(member, found.at.file, `${member.getText(found.at.file).trim()} - a command's argument is a field with its type`);
|
|
126
|
+
}
|
|
127
|
+
const name = member.name.text;
|
|
128
|
+
if (name === 'player' && player) throw new ShapeError(member, found.at.file, 'player - the handler gets the player who typed the command as player: name the argument otherwise');
|
|
129
|
+
fields.set(name, { name, optional: !!member.questionToken, kind: kindOf(member.type, shapes, found.at), node: member });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const type = typeArgument.getText(at.file);
|
|
133
|
+
for (const arg of usage.args) {
|
|
134
|
+
const field = fields.get(arg.name);
|
|
135
|
+
if (!field) throw new ShapeError(call.arguments[0], at.file, `${arg.name} - the usage has it and ${type} does not: add ${arg.name}${arg.optional ? '?' : ''}: string (or a number, a Player) to ${type}, or take it out of the usage`);
|
|
136
|
+
if (field.optional !== arg.optional) {
|
|
137
|
+
throw new ShapeError(field.node, found.at.file, arg.optional
|
|
138
|
+
? `${arg.name} - the usage has it optional, [${arg.name}]: write ${arg.name}?: in ${type}, or <${arg.name}> in the usage`
|
|
139
|
+
: `${arg.name} - the usage requires it, <${arg.name}>: write ${arg.name}: without the ? in ${type}, or [${arg.name}] in the usage`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
for (const [name, field] of fields) {
|
|
143
|
+
if (!usage.args.some(arg => arg.name === name)) throw new ShapeError(field.node, found.at.file, `${name} - ${type} has it and the usage does not: write <${name}> or [${name}] in the usage, or take it out of ${type}`);
|
|
144
|
+
}
|
|
145
|
+
return usage.args.map(arg => fields.get(arg.name)!);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The type of a field of the handler's argument in the generated class. */
|
|
149
|
+
function typeOf(arg: Argument): string {
|
|
150
|
+
if (arg.kind.kind === 'number') return 'number';
|
|
151
|
+
return arg.kind.kind === 'player' ? '__AmxtsPlayer' : 'string';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The parser's lines that read the word at `at` into the argument, indented by `tabs`. */
|
|
155
|
+
function readOf(arg: Argument, at: number, last: boolean, tabs: string): string[] {
|
|
156
|
+
switch (arg.kind.kind) {
|
|
157
|
+
case 'number': return [`${tabs}args.${arg.name} = words.number(${at});`];
|
|
158
|
+
case 'name': return [`${tabs}args.${arg.name} = words.name(${at}, ${JSON.stringify(arg.kind.names)});`];
|
|
159
|
+
case 'player': return [
|
|
160
|
+
`${tabs}const __${arg.name} = words.target(${at});`,
|
|
161
|
+
`${tabs}if (__${arg.name} == null) return;`,
|
|
162
|
+
`${tabs}args.${arg.name} = __${arg.name};`,
|
|
163
|
+
];
|
|
164
|
+
default: return [`${tabs}args.${arg.name} = words.${last ? 'rest' : 'text'}(${at});`];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The generated code for one call: the class of its handler's argument - an
|
|
170
|
+
* implementation of `shape`, the interface the call names - and the function
|
|
171
|
+
* that registers it.
|
|
172
|
+
*/
|
|
173
|
+
function commandCode(index: number, method: 'addCommand' | 'addServerCommand', args: Argument[], shape: string | undefined): string {
|
|
174
|
+
const player = method === 'addCommand';
|
|
175
|
+
const type = `__AmxtsCommandArgs${index}`;
|
|
176
|
+
const required = args.filter(arg => !arg.optional).length;
|
|
177
|
+
const lastIsText = args.length > 0 && args[args.length - 1].kind.kind === 'text';
|
|
178
|
+
const reads = args.flatMap((arg, at) => {
|
|
179
|
+
const lines = readOf(arg, at, at === args.length - 1, arg.optional ? '\t\t\t' : '\t\t');
|
|
180
|
+
return arg.optional ? [`\t\tif (words.count > ${at}) {`, ...lines, '\t\t}'] : lines;
|
|
181
|
+
});
|
|
182
|
+
return [
|
|
183
|
+
`class ${type}${shape ? ` implements ${shape}` : ''} {`,
|
|
184
|
+
...(player ? ['\tplayer!: __AmxtsPlayer;'] : []),
|
|
185
|
+
...args.map(arg => `\t${arg.name}${arg.optional ? '?' : '!'}: ${typeOf(arg)};`),
|
|
186
|
+
'}',
|
|
187
|
+
`function __amxtsCommand${index}(usage: string, handler: (args: ${type}) => void${player ? ', options: __AmxtsCommandOptions = {}' : ''}): void {`,
|
|
188
|
+
`\t__amxtsServer.__${method}(usage, (words: __AmxtsCommandWords): void => {`,
|
|
189
|
+
`\t\tconst args = new ${type}();`,
|
|
190
|
+
...(player ? ['\t\targs.player = words.player!;'] : []),
|
|
191
|
+
...(required > 0 ? [`\t\tif (!words.need(${required})) return;`] : []),
|
|
192
|
+
...reads,
|
|
193
|
+
...(lastIsText ? [] : [`\t\tif (!words.done(${args.length})) return;`]),
|
|
194
|
+
'\t\tif (words.failed) return;',
|
|
195
|
+
'\t\thandler(args);',
|
|
196
|
+
`\t}${player ? ', options' : ''});`,
|
|
197
|
+
'}',
|
|
198
|
+
].join('\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface TypedCommands {
|
|
202
|
+
text: string;
|
|
203
|
+
problems: string[];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The file with its commands' calls made the generated functions'; what does not build, with its place. */
|
|
207
|
+
export function typedCommands(path: string, display: string, text: string, imports: ImportReader): TypedCommands {
|
|
208
|
+
if (!text.includes('addCommand') && !text.includes('addServerCommand')) return { text, problems: [] };
|
|
209
|
+
|
|
210
|
+
const shapes = new Shapes(imports);
|
|
211
|
+
const file = shapes.parsed(path, text);
|
|
212
|
+
// The facade's `server`, by the names this file imports it as.
|
|
213
|
+
const servers = new Set<string>();
|
|
214
|
+
for (const statement of file.statements) {
|
|
215
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== FACADE) continue;
|
|
216
|
+
const bindings = statement.importClause?.namedBindings;
|
|
217
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
218
|
+
for (const element of bindings.elements) {
|
|
219
|
+
if ((element.propertyName ?? element.name).text === 'server') servers.add(element.name.text);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!servers.size) return { text, problems: [] };
|
|
223
|
+
|
|
224
|
+
const edits: { start: number; end: number; with: string }[] = [];
|
|
225
|
+
const functions: string[] = [];
|
|
226
|
+
const problems: string[] = [];
|
|
227
|
+
const at: Place = { path, file };
|
|
228
|
+
|
|
229
|
+
const visit = (node: ts.Node) => {
|
|
230
|
+
ts.forEachChild(node, visit);
|
|
231
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return;
|
|
232
|
+
const method = node.expression.name.text;
|
|
233
|
+
if (method !== 'addCommand' && method !== 'addServerCommand') return;
|
|
234
|
+
if (!ts.isIdentifier(node.expression.expression) || !servers.has(node.expression.expression.text)) return;
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const usageNode = node.arguments[0];
|
|
238
|
+
if (!usageNode) throw new ShapeError(node, file, 'the usage is missing: server.addCommand("/hp", ({ player }) => ...)');
|
|
239
|
+
const usage = usageOf(usageNode, !!node.typeArguments?.length, file);
|
|
240
|
+
const name = `__amxtsCommand${functions.length}`;
|
|
241
|
+
const shape = node.typeArguments?.[0]?.getText(file);
|
|
242
|
+
functions.push(commandCode(functions.length, method, argumentsOf(node, usage, shapes, at, method === 'addCommand'), shape));
|
|
243
|
+
const start = node.expression.getStart(file);
|
|
244
|
+
const end = node.arguments.pos - 1;
|
|
245
|
+
edits.push({ start, end, with: name.padEnd(end - start) });
|
|
246
|
+
} catch (problem) {
|
|
247
|
+
if (!(problem instanceof ShapeError)) throw problem;
|
|
248
|
+
const place = problem.file.getLineAndCharacterOfPosition(problem.node.getStart(problem.file));
|
|
249
|
+
const where = problem.file === file ? display : problem.file.fileName;
|
|
250
|
+
problems.push(`${where}:${place.line + 1}:${place.character + 1}: server.${method} - ${problem.message}`);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
visit(file);
|
|
254
|
+
|
|
255
|
+
if (!edits.length) return { text, problems };
|
|
256
|
+
|
|
257
|
+
let out = text;
|
|
258
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, edit.start) + edit.with + out.slice(edit.end);
|
|
259
|
+
|
|
260
|
+
const code = ['// GENERATED by scripts/typed-commands.ts: the commands this file adds, each word read as its type says.', ...functions].join('\n');
|
|
261
|
+
const facade = `import { server as __amxtsServer, Player as __AmxtsPlayer, CommandOptions as __AmxtsCommandOptions, __CommandWords as __AmxtsCommandWords } from "${FACADE}";`;
|
|
262
|
+
return { text: withGenerated(path, out, code, facade), problems };
|
|
263
|
+
}
|