@pylonsync/sdk 0.12.0 → 0.13.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.d.ts +65 -0
- package/package.json +1 -1
- package/src/index.ts +125 -0
- package/src/shard-config.test.ts +40 -0
package/dist/index.d.ts
CHANGED
|
@@ -686,6 +686,8 @@ export interface AppManifest {
|
|
|
686
686
|
requiredEnv?: ManifestRequiredEnv[];
|
|
687
687
|
/** Production build settings (`pylon build`). */
|
|
688
688
|
build?: BuildConfig;
|
|
689
|
+
/** Realtime shard kinds whose simulation is a WebAssembly module. */
|
|
690
|
+
shards?: ManifestShard[];
|
|
689
691
|
}
|
|
690
692
|
/**
|
|
691
693
|
* Production build settings for `pylon build`. Dev (`pylon dev`) ignores the
|
|
@@ -735,6 +737,67 @@ export interface BuildConfig {
|
|
|
735
737
|
* files under `app/` are always copied. */
|
|
736
738
|
include?: string[];
|
|
737
739
|
}
|
|
740
|
+
/** One realtime shard kind. See {@link shard}. */
|
|
741
|
+
export interface ManifestShard {
|
|
742
|
+
/** The kind name `ctx.shards.create(kind, id)` takes. */
|
|
743
|
+
name: string;
|
|
744
|
+
/** The compiled module, relative to the app root. */
|
|
745
|
+
wasm: string;
|
|
746
|
+
/** A Cargo crate, relative to the app root, that `pylon shards build`
|
|
747
|
+
* compiles to `wasm`. `pylon dev` runs that build at start. */
|
|
748
|
+
crate?: string;
|
|
749
|
+
/** Snapshot and input codec. Default `"json"`. */
|
|
750
|
+
codec?: "json" | "msgpack";
|
|
751
|
+
/** Ticks per second. `0` ticks only when inputs arrive. Default 20. */
|
|
752
|
+
tickRate?: number;
|
|
753
|
+
/** Pass `tick` a constant `1 / tickRate` instead of the measured time,
|
|
754
|
+
* so a replay gives the same result. Default `true`. */
|
|
755
|
+
fixedTimestep?: boolean;
|
|
756
|
+
/** Subscribers one shard admits. Default 256. */
|
|
757
|
+
maxSubscribers?: number;
|
|
758
|
+
/** Shards of this kind that may run at once. Default 64. */
|
|
759
|
+
maxInstances?: number;
|
|
760
|
+
/** Memory cap per shard, in MiB. Default 64. */
|
|
761
|
+
memoryMb?: number;
|
|
762
|
+
/** Time budget for one tick (its inputs, `tick`, and the snapshots) or
|
|
763
|
+
* one authorize call, in milliseconds. A tick that runs longer stops the
|
|
764
|
+
* shard. Default 100. */
|
|
765
|
+
tickBudgetMs?: number;
|
|
766
|
+
/** Stop a shard after this many seconds with no subscribers and no
|
|
767
|
+
* inputs. `0` never stops it. Default 90. */
|
|
768
|
+
idleShutdownSecs?: number;
|
|
769
|
+
/** Per-subscriber input limits. */
|
|
770
|
+
input?: {
|
|
771
|
+
/** Sustained inputs per second. Default 120. */
|
|
772
|
+
ratePerSec?: number;
|
|
773
|
+
/** Inputs above the rate a subscriber may send in a burst. Default 240. */
|
|
774
|
+
burst?: number;
|
|
775
|
+
/** Inputs one subscriber may have queued. Default 256. */
|
|
776
|
+
maxQueued?: number;
|
|
777
|
+
/** Inputs applied per subscriber per tick. Default 32. */
|
|
778
|
+
maxPerTick?: number;
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* Declare a realtime shard kind whose simulation is a WebAssembly module.
|
|
783
|
+
* Build the module with the `pylon-shard-guest` Rust crate (or any
|
|
784
|
+
* toolchain that implements its ABI). The stock `pylon` binary runs it, on
|
|
785
|
+
* Pylon Cloud too.
|
|
786
|
+
*
|
|
787
|
+
* ```ts
|
|
788
|
+
* buildManifest({
|
|
789
|
+
* // ...
|
|
790
|
+
* shards: [
|
|
791
|
+
* shard({ name: "arena", wasm: "shards/arena.wasm", crate: "shards/arena", tickRate: 30 }),
|
|
792
|
+
* ],
|
|
793
|
+
* });
|
|
794
|
+
* ```
|
|
795
|
+
*
|
|
796
|
+
* A function starts a shard with `ctx.shards.create("arena", matchId, params)`
|
|
797
|
+
* and mints tickets with `ctx.shards.ticket(matchId)`. Clients connect with
|
|
798
|
+
* `useShard(matchId, { ticket })`.
|
|
799
|
+
*/
|
|
800
|
+
export declare function shard(def: ManifestShard): ManifestShard;
|
|
738
801
|
/** One environment variable the app declares it needs. */
|
|
739
802
|
export interface ManifestRequiredEnv {
|
|
740
803
|
name: string;
|
|
@@ -1173,6 +1236,8 @@ export declare function buildManifest(options: {
|
|
|
1173
1236
|
requiredEnv?: ManifestRequiredEnv[];
|
|
1174
1237
|
/** Production build settings for `pylon build`. See `BuildConfig`. */
|
|
1175
1238
|
build?: BuildConfig;
|
|
1239
|
+
/** Realtime shard kinds. See `shard`. */
|
|
1240
|
+
shards?: ManifestShard[];
|
|
1176
1241
|
/** Set by `discoverFunctions()` (spread its result into this call).
|
|
1177
1242
|
* When true, the framework's AgentRun/AgentMessage entities and
|
|
1178
1243
|
* their owner-scoping policies are appended to the manifest —
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -875,6 +875,8 @@ export interface AppManifest {
|
|
|
875
875
|
requiredEnv?: ManifestRequiredEnv[];
|
|
876
876
|
/** Production build settings (`pylon build`). */
|
|
877
877
|
build?: BuildConfig;
|
|
878
|
+
/** Realtime shard kinds whose simulation is a WebAssembly module. */
|
|
879
|
+
shards?: ManifestShard[];
|
|
878
880
|
}
|
|
879
881
|
|
|
880
882
|
/**
|
|
@@ -964,6 +966,116 @@ function validateBuildConfig(build: BuildConfig): void {
|
|
|
964
966
|
}
|
|
965
967
|
}
|
|
966
968
|
|
|
969
|
+
/** One realtime shard kind. See {@link shard}. */
|
|
970
|
+
export interface ManifestShard {
|
|
971
|
+
/** The kind name `ctx.shards.create(kind, id)` takes. */
|
|
972
|
+
name: string;
|
|
973
|
+
/** The compiled module, relative to the app root. */
|
|
974
|
+
wasm: string;
|
|
975
|
+
/** A Cargo crate, relative to the app root, that `pylon shards build`
|
|
976
|
+
* compiles to `wasm`. `pylon dev` runs that build at start. */
|
|
977
|
+
crate?: string;
|
|
978
|
+
/** Snapshot and input codec. Default `"json"`. */
|
|
979
|
+
codec?: "json" | "msgpack";
|
|
980
|
+
/** Ticks per second. `0` ticks only when inputs arrive. Default 20. */
|
|
981
|
+
tickRate?: number;
|
|
982
|
+
/** Pass `tick` a constant `1 / tickRate` instead of the measured time,
|
|
983
|
+
* so a replay gives the same result. Default `true`. */
|
|
984
|
+
fixedTimestep?: boolean;
|
|
985
|
+
/** Subscribers one shard admits. Default 256. */
|
|
986
|
+
maxSubscribers?: number;
|
|
987
|
+
/** Shards of this kind that may run at once. Default 64. */
|
|
988
|
+
maxInstances?: number;
|
|
989
|
+
/** Memory cap per shard, in MiB. Default 64. */
|
|
990
|
+
memoryMb?: number;
|
|
991
|
+
/** Time budget for one tick (its inputs, `tick`, and the snapshots) or
|
|
992
|
+
* one authorize call, in milliseconds. A tick that runs longer stops the
|
|
993
|
+
* shard. Default 100. */
|
|
994
|
+
tickBudgetMs?: number;
|
|
995
|
+
/** Stop a shard after this many seconds with no subscribers and no
|
|
996
|
+
* inputs. `0` never stops it. Default 90. */
|
|
997
|
+
idleShutdownSecs?: number;
|
|
998
|
+
/** Per-subscriber input limits. */
|
|
999
|
+
input?: {
|
|
1000
|
+
/** Sustained inputs per second. Default 120. */
|
|
1001
|
+
ratePerSec?: number;
|
|
1002
|
+
/** Inputs above the rate a subscriber may send in a burst. Default 240. */
|
|
1003
|
+
burst?: number;
|
|
1004
|
+
/** Inputs one subscriber may have queued. Default 256. */
|
|
1005
|
+
maxQueued?: number;
|
|
1006
|
+
/** Inputs applied per subscriber per tick. Default 32. */
|
|
1007
|
+
maxPerTick?: number;
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Declare a realtime shard kind whose simulation is a WebAssembly module.
|
|
1013
|
+
* Build the module with the `pylon-shard-guest` Rust crate (or any
|
|
1014
|
+
* toolchain that implements its ABI). The stock `pylon` binary runs it, on
|
|
1015
|
+
* Pylon Cloud too.
|
|
1016
|
+
*
|
|
1017
|
+
* ```ts
|
|
1018
|
+
* buildManifest({
|
|
1019
|
+
* // ...
|
|
1020
|
+
* shards: [
|
|
1021
|
+
* shard({ name: "arena", wasm: "shards/arena.wasm", crate: "shards/arena", tickRate: 30 }),
|
|
1022
|
+
* ],
|
|
1023
|
+
* });
|
|
1024
|
+
* ```
|
|
1025
|
+
*
|
|
1026
|
+
* A function starts a shard with `ctx.shards.create("arena", matchId, params)`
|
|
1027
|
+
* and mints tickets with `ctx.shards.ticket(matchId)`. Clients connect with
|
|
1028
|
+
* `useShard(matchId, { ticket })`.
|
|
1029
|
+
*/
|
|
1030
|
+
export function shard(def: ManifestShard): ManifestShard {
|
|
1031
|
+
validateShard(def);
|
|
1032
|
+
return def;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const SHARD_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
1036
|
+
|
|
1037
|
+
/** Throws when a shard kind has a value the runtime cannot use. */
|
|
1038
|
+
function validateShard(def: ManifestShard): void {
|
|
1039
|
+
const fail = (msg: string): never => {
|
|
1040
|
+
throw new Error(`shard(${JSON.stringify(def?.name ?? "")}): ${msg}`);
|
|
1041
|
+
};
|
|
1042
|
+
if (typeof def?.name !== "string" || !SHARD_NAME.test(def.name)) {
|
|
1043
|
+
fail("name must start with a letter and hold up to 64 letters, digits, '_', '-'");
|
|
1044
|
+
}
|
|
1045
|
+
const checkPath = (value: unknown, field: string) => {
|
|
1046
|
+
if (typeof value !== "string" || value === "") fail(`${field} must be a non-empty path`);
|
|
1047
|
+
const p = (value as string).replace(/\\/g, "/");
|
|
1048
|
+
if (p.startsWith("/") || /^[A-Za-z]:/.test(p) || p.split("/").includes("..")) {
|
|
1049
|
+
fail(`${field} must be relative to the app root, without ".."`);
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
checkPath(def.wasm, "wasm");
|
|
1053
|
+
if (!def.wasm.endsWith(".wasm")) fail(`wasm must name a .wasm file`);
|
|
1054
|
+
if (def.crate !== undefined) checkPath(def.crate, "crate");
|
|
1055
|
+
if (def.codec !== undefined && def.codec !== "json" && def.codec !== "msgpack") {
|
|
1056
|
+
fail(`codec must be "json" or "msgpack"`);
|
|
1057
|
+
}
|
|
1058
|
+
if (def.fixedTimestep !== undefined && typeof def.fixedTimestep !== "boolean") {
|
|
1059
|
+
fail("fixedTimestep must be a boolean");
|
|
1060
|
+
}
|
|
1061
|
+
const checkInt = (value: unknown, field: string, min: number, max: number) => {
|
|
1062
|
+
if (value === undefined) return;
|
|
1063
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
1064
|
+
fail(`${field} must be an integer from ${min} to ${max}`);
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
1067
|
+
checkInt(def.tickRate, "tickRate", 0, 1000);
|
|
1068
|
+
checkInt(def.maxSubscribers, "maxSubscribers", 1, 1_000_000);
|
|
1069
|
+
checkInt(def.maxInstances, "maxInstances", 1, 100_000);
|
|
1070
|
+
checkInt(def.memoryMb, "memoryMb", 1, 4096);
|
|
1071
|
+
checkInt(def.tickBudgetMs, "tickBudgetMs", 1, 60_000);
|
|
1072
|
+
checkInt(def.idleShutdownSecs, "idleShutdownSecs", 0, 31_536_000);
|
|
1073
|
+
checkInt(def.input?.ratePerSec, "input.ratePerSec", 1, 100_000);
|
|
1074
|
+
checkInt(def.input?.burst, "input.burst", 1, 100_000);
|
|
1075
|
+
checkInt(def.input?.maxQueued, "input.maxQueued", 1, 100_000);
|
|
1076
|
+
checkInt(def.input?.maxPerTick, "input.maxPerTick", 1, 100_000);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
967
1079
|
/** One environment variable the app declares it needs. */
|
|
968
1080
|
export interface ManifestRequiredEnv {
|
|
969
1081
|
name: string;
|
|
@@ -2241,6 +2353,8 @@ export function buildManifest(options: {
|
|
|
2241
2353
|
requiredEnv?: ManifestRequiredEnv[];
|
|
2242
2354
|
/** Production build settings for `pylon build`. See `BuildConfig`. */
|
|
2243
2355
|
build?: BuildConfig;
|
|
2356
|
+
/** Realtime shard kinds. See `shard`. */
|
|
2357
|
+
shards?: ManifestShard[];
|
|
2244
2358
|
/** Set by `discoverFunctions()` (spread its result into this call).
|
|
2245
2359
|
* When true, the framework's AgentRun/AgentMessage entities and
|
|
2246
2360
|
* their owner-scoping policies are appended to the manifest —
|
|
@@ -2262,6 +2376,14 @@ export function buildManifest(options: {
|
|
|
2262
2376
|
// derive from the entity + a counter so two attached policies
|
|
2263
2377
|
// don't collide.
|
|
2264
2378
|
if (options.build) validateBuildConfig(options.build);
|
|
2379
|
+
const shardNames = new Set<string>();
|
|
2380
|
+
for (const def of options.shards ?? []) {
|
|
2381
|
+
validateShard(def);
|
|
2382
|
+
if (shardNames.has(def.name)) {
|
|
2383
|
+
throw new Error(`buildManifest: two shard kinds are named "${def.name}"`);
|
|
2384
|
+
}
|
|
2385
|
+
shardNames.add(def.name);
|
|
2386
|
+
}
|
|
2265
2387
|
const attached: PolicyDefinition[] = [];
|
|
2266
2388
|
for (const ent of options.entities) {
|
|
2267
2389
|
const extracted = extractAttachedPolicies(ent);
|
|
@@ -2328,6 +2450,9 @@ export function buildManifest(options: {
|
|
|
2328
2450
|
...(options.build && Object.keys(options.build).length > 0
|
|
2329
2451
|
? { build: options.build }
|
|
2330
2452
|
: {}),
|
|
2453
|
+
...(options.shards && options.shards.length > 0
|
|
2454
|
+
? { shards: options.shards }
|
|
2455
|
+
: {}),
|
|
2331
2456
|
};
|
|
2332
2457
|
}
|
|
2333
2458
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shard({...})` and `buildManifest({ shards })`: shard kinds reach the
|
|
3
|
+
* manifest as written, and a value the runtime cannot use fails when app.ts
|
|
4
|
+
* runs.
|
|
5
|
+
*/
|
|
6
|
+
import { expect, test } from "bun:test";
|
|
7
|
+
import { buildManifest, entity, field, shard } from "./index";
|
|
8
|
+
|
|
9
|
+
const base = {
|
|
10
|
+
name: "t",
|
|
11
|
+
version: "0",
|
|
12
|
+
entities: [entity("Doc", { title: field.string() })],
|
|
13
|
+
routes: [],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
test("shard kinds are copied into the manifest", () => {
|
|
17
|
+
const arena = shard({
|
|
18
|
+
name: "arena",
|
|
19
|
+
wasm: "shards/arena.wasm",
|
|
20
|
+
crate: "shards/arena",
|
|
21
|
+
codec: "msgpack",
|
|
22
|
+
tickRate: 30,
|
|
23
|
+
maxInstances: 8,
|
|
24
|
+
input: { ratePerSec: 60 },
|
|
25
|
+
});
|
|
26
|
+
expect(buildManifest({ ...base, shards: [arena] }).shards).toEqual([arena]);
|
|
27
|
+
expect("shards" in buildManifest(base)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("bad shard kinds throw", () => {
|
|
31
|
+
expect(() => shard({ name: "1bad", wasm: "a.wasm" })).toThrow("name must start");
|
|
32
|
+
expect(() => shard({ name: "a", wasm: "../a.wasm" })).toThrow("relative to the app root");
|
|
33
|
+
expect(() => shard({ name: "a", wasm: "/abs/a.wasm" })).toThrow("relative to the app root");
|
|
34
|
+
expect(() => shard({ name: "a", wasm: "a.js" })).toThrow(".wasm file");
|
|
35
|
+
expect(() => shard({ name: "a", wasm: "a.wasm", codec: "bincode" as never })).toThrow("codec");
|
|
36
|
+
expect(() => shard({ name: "a", wasm: "a.wasm", tickRate: 1.5 })).toThrow("tickRate");
|
|
37
|
+
expect(() => shard({ name: "a", wasm: "a.wasm", memoryMb: 0 })).toThrow("memoryMb");
|
|
38
|
+
const a = { name: "a", wasm: "a.wasm" };
|
|
39
|
+
expect(() => buildManifest({ ...base, shards: [a, a] })).toThrow('two shard kinds are named "a"');
|
|
40
|
+
});
|