@stage5/lumine 0.1.9 → 0.2.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/README.md +19 -0
- package/bin/lumine.js +532 -3
- package/package.json +1 -1
- package/sdk/BUILD_SDK_INDEX.md +78 -19
package/README.md
CHANGED
|
@@ -58,6 +58,25 @@ Reference folders are marked `readOnly` in `.twinkle/lumine-project.json`.
|
|
|
58
58
|
Running `lumine save` from a reference folder is blocked; fork the source Build
|
|
59
59
|
first if you want an editable workspace.
|
|
60
60
|
|
|
61
|
+
## Inspecting Build SDK data
|
|
62
|
+
|
|
63
|
+
`lumine sdk call <namespace.method> '<jsonArgs>'` calls a build's data SDK
|
|
64
|
+
endpoint with your login and prints the response, so you can inspect real data
|
|
65
|
+
and measure latency while building. Run `lumine sdk list` to see callable
|
|
66
|
+
methods. The JSON args are sent as the request body (shapes follow
|
|
67
|
+
`TWINKLE_BUILD_SDK.md`).
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
lumine sdk call aiStories.chapters '{"limit": 5}'
|
|
71
|
+
lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
It targets the build in the current workspace, or pass `--build <id>`. Add
|
|
75
|
+
`--repeat <n>` for min/avg/max latency. Output is the raw endpoint response,
|
|
76
|
+
which can differ from a method's `Twinkle.*` SDK return shape — check
|
|
77
|
+
`TWINKLE_BUILD_SDK.md` for SDK return shapes. Methods that change data require
|
|
78
|
+
`--allow-write`.
|
|
79
|
+
|
|
61
80
|
The CLI checks npm for the latest `@stage5/lumine` version on normal commands.
|
|
62
81
|
If the installed copy is outdated, it prints an update warning and records the
|
|
63
82
|
version state in `.twinkle/lumine-project.json` so local agents can tell when
|
package/bin/lumine.js
CHANGED
|
@@ -83,6 +83,12 @@ lumine save --summary "Describe the change"
|
|
|
83
83
|
\`\`\`
|
|
84
84
|
|
|
85
85
|
- Run lumine check before launch when possible.
|
|
86
|
+
- Use \`lumine sdk call <namespace.method> '{...}'\` to inspect real endpoint
|
|
87
|
+
data and measure latency (add --repeat <n>); \`lumine sdk list\` shows
|
|
88
|
+
callable methods. It prints the raw HTTP endpoint response, which can differ
|
|
89
|
+
from a method's Twinkle.* SDK return shape (some wrappers unwrap or rename
|
|
90
|
+
fields) — use ${SDK_REFERENCE_FILE} for SDK return shapes. Write methods
|
|
91
|
+
need --allow-write and mutate real app data.
|
|
86
92
|
- Owned canonical builds may be published only when the user explicitly asks.
|
|
87
93
|
|
|
88
94
|
## App Constraints
|
|
@@ -133,6 +139,7 @@ const COMMANDS = new Set([
|
|
|
133
139
|
"push",
|
|
134
140
|
"check",
|
|
135
141
|
"launch",
|
|
142
|
+
"sdk",
|
|
136
143
|
"help",
|
|
137
144
|
]);
|
|
138
145
|
|
|
@@ -220,6 +227,10 @@ async function main() {
|
|
|
220
227
|
await launch(options);
|
|
221
228
|
return;
|
|
222
229
|
}
|
|
230
|
+
if (options.command === "sdk") {
|
|
231
|
+
await sdkCommand(options);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
223
234
|
|
|
224
235
|
printHelp();
|
|
225
236
|
}
|
|
@@ -230,7 +241,13 @@ async function login(options) {
|
|
|
230
241
|
url: `${options.apiUrl}/cli/device/start`,
|
|
231
242
|
body: {
|
|
232
243
|
clientName: options.clientName,
|
|
233
|
-
scopes: [
|
|
244
|
+
scopes: [
|
|
245
|
+
"build:read",
|
|
246
|
+
"build:write",
|
|
247
|
+
"build:check",
|
|
248
|
+
"build:publish",
|
|
249
|
+
"build:sdk",
|
|
250
|
+
],
|
|
234
251
|
},
|
|
235
252
|
timeoutMs: options.timeoutMs,
|
|
236
253
|
});
|
|
@@ -702,6 +719,490 @@ async function ensureAuth(options) {
|
|
|
702
719
|
return await resolveAuth(options);
|
|
703
720
|
}
|
|
704
721
|
|
|
722
|
+
// Build SDK data-API methods callable from the CLI. Method names match the
|
|
723
|
+
// SDK manifest (constants/buildSdkIndex.json in twinkle-api); paths are the
|
|
724
|
+
// HTTP endpoints the Build runtime parent proxies those methods to. The args
|
|
725
|
+
// JSON object is sent verbatim as the POST body, so arg shapes follow
|
|
726
|
+
// TWINKLE_BUILD_SDK.md. Methods that mutate data require --allow-write.
|
|
727
|
+
// Anything not listed can be called with --path api/<endpoint>.
|
|
728
|
+
const SDK_CLI_METHODS = {
|
|
729
|
+
"aiStories.list": { path: "api/content/ai-stories/list", scopes: ["content:read"] },
|
|
730
|
+
"aiStories.chapters": { path: "api/content/ai-stories/chapters", scopes: ["content:read"] },
|
|
731
|
+
"aiStories.search": { path: "api/content/ai-stories/search", scopes: ["content:read"] },
|
|
732
|
+
"aiStories.get": { path: "api/content/ai-story", scopes: ["content:read"] },
|
|
733
|
+
"aiCards.list": { path: "api/content/ai-cards/list", scopes: ["content:read"] },
|
|
734
|
+
"aiCards.search": { path: "api/content/ai-cards/search", scopes: ["content:read"] },
|
|
735
|
+
"aiCards.get": { path: "api/content/ai-card", scopes: ["content:read"] },
|
|
736
|
+
"grammarbles.listQuestions": { path: "api/content/grammarbles/questions", scopes: ["content:read"] },
|
|
737
|
+
"grammarbles.getMyQuestionHistory": { path: "api/content/grammarbles/history", scopes: ["content:read"] },
|
|
738
|
+
"subjects.getMySubjects": { path: "api/content/my-subjects", scopes: ["content:read"] },
|
|
739
|
+
"subjects.search": { path: "api/content/subjects/search", scopes: ["content:read"] },
|
|
740
|
+
"subjects.getSubject": { path: "api/content/subject", scopes: ["content:read"] },
|
|
741
|
+
"subjects.getSubjectComments": { path: "api/content/subject-comments", scopes: ["content:read"] },
|
|
742
|
+
"subjectComments.list": { path: "api/content/subject-comment-list", scopes: ["content:read"] },
|
|
743
|
+
"profileComments.getProfileComments": { path: "api/content/profile-comments", scopes: ["content:read"] },
|
|
744
|
+
"profileComments.getProfileCommentIds": { path: "api/content/profile-comment-ids", scopes: ["content:read"] },
|
|
745
|
+
"profileComments.getCommentsByIds": { path: "api/content/profile-comments-by-ids", scopes: ["content:read"] },
|
|
746
|
+
"profileComments.getProfileCommentCounts": { path: "api/content/profile-comment-counts", scopes: ["content:read"] },
|
|
747
|
+
"privateDb.get": { path: "api/private-db/get", scopes: ["privateDb:read"] },
|
|
748
|
+
"privateDb.list": { path: "api/private-db/list", scopes: ["privateDb:read"] },
|
|
749
|
+
"privateDb.set": { path: "api/private-db/set", scopes: ["privateDb:write"], write: true },
|
|
750
|
+
"privateDb.remove": { path: "api/private-db/delete", scopes: ["privateDb:write"], write: true },
|
|
751
|
+
"sharedDb.getTopics": { path: "api/shared-db/topics", scopes: ["sharedDb:read"] },
|
|
752
|
+
"sharedDb.createTopic": { path: "api/shared-db/topic", scopes: ["sharedDb:write"], write: true },
|
|
753
|
+
"sharedDb.getEntries": { path: "api/shared-db/entries", scopes: ["sharedDb:read"] },
|
|
754
|
+
"sharedDb.addEntry": { path: "api/shared-db/entry", scopes: ["sharedDb:write"], write: true },
|
|
755
|
+
"sharedDb.updateEntry": { path: "api/shared-db/entry/update", scopes: ["sharedDb:write"], write: true },
|
|
756
|
+
"sharedDb.deleteEntry": { path: "api/shared-db/entry/delete", scopes: ["sharedDb:write"], write: true },
|
|
757
|
+
"sharedDb.kv.get": { path: "api/shared-db/kv/get", scopes: ["sharedDb:read"] },
|
|
758
|
+
"sharedDb.kv.list": { path: "api/shared-db/kv/list", scopes: ["sharedDb:read"] },
|
|
759
|
+
// The kv/set endpoint is batch-shaped ({ namespace, items }); kv.set keeps
|
|
760
|
+
// the SDK's single-key signature and maps to one item.
|
|
761
|
+
"sharedDb.kv.set": {
|
|
762
|
+
path: "api/shared-db/kv/set",
|
|
763
|
+
scopes: ["sharedDb:write"],
|
|
764
|
+
write: true,
|
|
765
|
+
mapArgs: (args) => ({
|
|
766
|
+
namespace: args.namespace,
|
|
767
|
+
items: [{ key: args.key, value: args.value }],
|
|
768
|
+
}),
|
|
769
|
+
sdkReshape: "the SDK takes (namespace, key, value) and returns { item }",
|
|
770
|
+
},
|
|
771
|
+
"sharedDb.kv.setMany": { path: "api/shared-db/kv/set", scopes: ["sharedDb:write"], write: true },
|
|
772
|
+
"sharedDb.kv.remove": { path: "api/shared-db/kv/delete", scopes: ["sharedDb:write"], write: true },
|
|
773
|
+
"chat.listRooms": { path: "api/chat/rooms/list", scopes: ["chat:read"] },
|
|
774
|
+
"chat.createRoom": { path: "api/chat/rooms/create", scopes: ["chat:write"], write: true },
|
|
775
|
+
"chat.listMessages": { path: "api/chat/messages/list", scopes: ["chat:read"] },
|
|
776
|
+
"chat.sendMessage": { path: "api/chat/messages/send", scopes: ["chat:write"], write: true },
|
|
777
|
+
"chat.deleteMessage": { path: "api/chat/messages/delete", scopes: ["chat:write"], write: true },
|
|
778
|
+
"users.getUser": {
|
|
779
|
+
path: "api/user",
|
|
780
|
+
scopes: ["user:read"],
|
|
781
|
+
sdkReshape: "the SDK returns the user object directly (unwraps { user })",
|
|
782
|
+
},
|
|
783
|
+
"users.getUsers": { path: "api/users", scopes: ["users:read"] },
|
|
784
|
+
"reflections.getDailyReflections": { path: "api/daily-reflections", scopes: ["dailyReflections:read"] },
|
|
785
|
+
"reminders.list": { path: "api/reminders/list", scopes: ["reminders:read"] },
|
|
786
|
+
// getDue's backend defaults autoAcknowledge to true, which persists writes
|
|
787
|
+
// (lastTriggeredAt, and disabling one-shot reminders). Force read-only
|
|
788
|
+
// probes to autoAcknowledge:false; acknowledging is a write that needs
|
|
789
|
+
// --allow-write.
|
|
790
|
+
"reminders.getDue": {
|
|
791
|
+
path: "api/reminders/due",
|
|
792
|
+
scopes: ["reminders:read"],
|
|
793
|
+
writeWhen: (args) => args.autoAcknowledge === true,
|
|
794
|
+
mapArgs: (args) => ({
|
|
795
|
+
...args,
|
|
796
|
+
autoAcknowledge: args.autoAcknowledge === true,
|
|
797
|
+
}),
|
|
798
|
+
},
|
|
799
|
+
"reminders.create": { path: "api/reminders/create", scopes: ["reminders:write"], write: true },
|
|
800
|
+
"reminders.update": { path: "api/reminders/update", scopes: ["reminders:write"], write: true },
|
|
801
|
+
"reminders.remove": { path: "api/reminders/delete", scopes: ["reminders:write"], write: true },
|
|
802
|
+
"files.list": {
|
|
803
|
+
path: "api/files/list",
|
|
804
|
+
scopes: ["files:read"],
|
|
805
|
+
sdkReshape: "the SDK returns { assets, usage }",
|
|
806
|
+
},
|
|
807
|
+
"files.delete": {
|
|
808
|
+
path: "api/files/delete",
|
|
809
|
+
scopes: ["files:write"],
|
|
810
|
+
write: true,
|
|
811
|
+
sdkReshape: "the SDK returns { success, usage }",
|
|
812
|
+
},
|
|
813
|
+
"notifications.getSubscription": { path: "api/notifications/subscription", scopes: ["notifications:read"] },
|
|
814
|
+
"notifications.subscribe": { path: "api/notifications/subscription/subscribe", scopes: ["notifications:write"], write: true },
|
|
815
|
+
"notifications.unsubscribe": { path: "api/notifications/subscription/unsubscribe", scopes: ["notifications:write"], write: true },
|
|
816
|
+
"notifications.subscribeMany": { path: "api/notifications/subscriptions/subscribe", scopes: ["notifications:write"], write: true },
|
|
817
|
+
"notifications.unsubscribeMany": { path: "api/notifications/subscriptions/unsubscribe", scopes: ["notifications:write"], write: true },
|
|
818
|
+
"notifications.notifySubscribers": { path: "api/notifications/notify-subscribers", scopes: ["notifications:emit"], write: true },
|
|
819
|
+
"notifications.getSubjectUpdateSubscription": { path: "api/notifications/subject-update-subscription", scopes: ["notifications:read"] },
|
|
820
|
+
"notifications.subscribeToSubjectUpdates": { path: "api/notifications/subject-update-subscription/subscribe", scopes: ["notifications:write"], write: true },
|
|
821
|
+
"notifications.unsubscribeFromSubjectUpdates": { path: "api/notifications/subject-update-subscription/unsubscribe", scopes: ["notifications:write"], write: true },
|
|
822
|
+
// Leaderboards use the public leaderboard routes (regular login auth, no
|
|
823
|
+
// build API token). args.boardKey selects the board; remaining args are
|
|
824
|
+
// query params (get) or the POST body (submit).
|
|
825
|
+
"leaderboards.get": { special: "leaderboardGet" },
|
|
826
|
+
"leaderboards.submit": { special: "leaderboardSubmit", write: true },
|
|
827
|
+
// userDb (the viewer's private SQLite) lives at /viewer-db/*, which uses
|
|
828
|
+
// session auth directly — no build API token. query reads, exec mutates.
|
|
829
|
+
// Body is { sql, params }. The backend's query route accepts any
|
|
830
|
+
// row-returning statement, including INSERT/UPDATE/DELETE ... RETURNING,
|
|
831
|
+
// so SQL that mutates is gated behind --allow-write here too.
|
|
832
|
+
"userDb.query": { special: "userDbQuery", writeWhen: (args) => sqlMutates(args.sql) },
|
|
833
|
+
"userDb.exec": { special: "userDbExec", write: true },
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
// Heuristic write-detection for raw SQL. SELECT/EXPLAIN/VALUES cannot mutate;
|
|
837
|
+
// a WITH (CTE) can wrap DML; everything else is treated as write-capable.
|
|
838
|
+
// Errs toward over-gating (safe) rather than under-gating.
|
|
839
|
+
function sqlMutates(sql) {
|
|
840
|
+
const normalized = String(sql || "")
|
|
841
|
+
.trim()
|
|
842
|
+
.replace(/^\(+/, "");
|
|
843
|
+
const leadingKeyword = (normalized.match(/^\s*([a-z]+)/i)?.[1] || "").toUpperCase();
|
|
844
|
+
if (
|
|
845
|
+
leadingKeyword === "SELECT" ||
|
|
846
|
+
leadingKeyword === "EXPLAIN" ||
|
|
847
|
+
leadingKeyword === "VALUES"
|
|
848
|
+
) {
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
if (leadingKeyword === "WITH") {
|
|
852
|
+
return /\b(INSERT|UPDATE|DELETE|REPLACE)\b/i.test(normalized);
|
|
853
|
+
}
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Reverse index of curated endpoint paths -> method name(s). Used to reject
|
|
858
|
+
// raw --path calls to curated endpoints: --path is for endpoints not in the
|
|
859
|
+
// list, and routing a curated endpoint through --path would strip its
|
|
860
|
+
// write/writeWhen/mapArgs safety handling (e.g. api/reminders/due defaults
|
|
861
|
+
// autoAcknowledge to true and mutates under a read scope).
|
|
862
|
+
const SDK_CLI_METHOD_NAMES_BY_PATH = (() => {
|
|
863
|
+
const byPath = new Map();
|
|
864
|
+
for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
|
|
865
|
+
if (!entry.path) continue;
|
|
866
|
+
if (!byPath.has(entry.path)) byPath.set(entry.path, []);
|
|
867
|
+
byPath.get(entry.path).push(name);
|
|
868
|
+
}
|
|
869
|
+
return byPath;
|
|
870
|
+
})();
|
|
871
|
+
|
|
872
|
+
// Default token scopes for --path calls. Never fall through to the server's
|
|
873
|
+
// default scope set: it includes write/emit scopes, which must not be minted
|
|
874
|
+
// without --allow-write.
|
|
875
|
+
const SDK_CLI_READ_SCOPES = [
|
|
876
|
+
"files:read",
|
|
877
|
+
"user:read",
|
|
878
|
+
"users:read",
|
|
879
|
+
"dailyReflections:read",
|
|
880
|
+
"content:read",
|
|
881
|
+
"sharedDb:read",
|
|
882
|
+
"privateDb:read",
|
|
883
|
+
"chat:read",
|
|
884
|
+
"notifications:read",
|
|
885
|
+
"reminders:read",
|
|
886
|
+
];
|
|
887
|
+
|
|
888
|
+
function isWriteCapableScope(scope) {
|
|
889
|
+
return /:(write|emit)$/.test(String(scope));
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function sdkCommand(options) {
|
|
893
|
+
const subcommand = String(options.positional[0] || "list");
|
|
894
|
+
if (subcommand === "list") {
|
|
895
|
+
printSdkMethodList();
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (subcommand !== "call") {
|
|
899
|
+
throw new Error(
|
|
900
|
+
'Usage: lumine sdk list | lumine sdk call <namespace.method> [jsonArgs]',
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
await sdkCall(options);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function printSdkMethodList() {
|
|
907
|
+
console.log(
|
|
908
|
+
"Callable Build endpoints (args JSON = request body). Prints the raw\n" +
|
|
909
|
+
"endpoint response, which can differ from the SDK return shape (*).\n",
|
|
910
|
+
);
|
|
911
|
+
const byNamespace = new Map();
|
|
912
|
+
for (const [name, entry] of Object.entries(SDK_CLI_METHODS)) {
|
|
913
|
+
const namespace = name.split(".")[0];
|
|
914
|
+
if (!byNamespace.has(namespace)) byNamespace.set(namespace, []);
|
|
915
|
+
byNamespace.get(namespace).push({ name, entry });
|
|
916
|
+
}
|
|
917
|
+
for (const [namespace, methods] of byNamespace) {
|
|
918
|
+
console.log(` ${namespace}`);
|
|
919
|
+
for (const { name, entry } of methods) {
|
|
920
|
+
const writeTag = entry.write ? " [write: needs --allow-write]" : "";
|
|
921
|
+
const reshapeTag = entry.sdkReshape ? " *" : "";
|
|
922
|
+
const pathText = entry.path || `(${entry.special})`;
|
|
923
|
+
console.log(
|
|
924
|
+
` ${name.padEnd(44)} ${pathText}${writeTag}${reshapeTag}`,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
console.log(`
|
|
929
|
+
Usage:
|
|
930
|
+
lumine sdk call aiStories.chapters '{"limit": 5}'
|
|
931
|
+
lumine sdk call privateDb.set '{"key": "k", "value": {"a": 1}}' --allow-write
|
|
932
|
+
lumine sdk call leaderboards.get '{"boardKey": "default", "limit": 10}'
|
|
933
|
+
lumine sdk call aiStories.list '{"difficulty": 1}' --repeat 5 --build 1374
|
|
934
|
+
lumine sdk call userDb.query '{"sql": "SELECT * FROM sqlite_master"}'
|
|
935
|
+
|
|
936
|
+
Request-body (args) shapes follow TWINKLE_BUILD_SDK.md. Output is the raw
|
|
937
|
+
endpoint response; (*) methods are reshaped by their Twinkle.* SDK wrapper, so
|
|
938
|
+
check TWINKLE_BUILD_SDK.md for the real SDK return shape. Add --repeat <n> for
|
|
939
|
+
latency stats, --build <id> to target a build outside the current workspace.
|
|
940
|
+
Use --path api/<endpoint> only for endpoints not listed above.`);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function sdkCall(options) {
|
|
944
|
+
const usingRawPath = Boolean(options.sdkPath);
|
|
945
|
+
const methodName = usingRawPath ? "" : String(options.positional[1] || "");
|
|
946
|
+
const argsText = String(
|
|
947
|
+
(usingRawPath ? options.positional[1] : options.positional[2]) || "",
|
|
948
|
+
).trim();
|
|
949
|
+
|
|
950
|
+
let args = {};
|
|
951
|
+
if (argsText) {
|
|
952
|
+
try {
|
|
953
|
+
args = JSON.parse(argsText);
|
|
954
|
+
} catch (error) {
|
|
955
|
+
throw new Error(`Args must be valid JSON: ${error.message}`);
|
|
956
|
+
}
|
|
957
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
958
|
+
throw new Error("Args must be a JSON object, e.g. '{\"limit\": 5}'.");
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
let endpoint;
|
|
963
|
+
if (usingRawPath) {
|
|
964
|
+
let normalizedPath = String(options.sdkPath).replace(/^\/+/, "");
|
|
965
|
+
if (!/^api\/[a-zA-Z0-9/_-]+$/.test(normalizedPath)) {
|
|
966
|
+
throw new Error("--path must look like api/<endpoint> (no query string).");
|
|
967
|
+
}
|
|
968
|
+
// Normalize the way Express routes (case-insensitive, non-strict, slashes
|
|
969
|
+
// collapsed) so route-equivalent variants like api/Token or
|
|
970
|
+
// api/reminders/due/ cannot dodge the guards below. Guard and call the
|
|
971
|
+
// same normalized path so the checked string is the executed string.
|
|
972
|
+
normalizedPath = normalizedPath
|
|
973
|
+
.toLowerCase()
|
|
974
|
+
.replace(/\/{2,}/g, "/")
|
|
975
|
+
.replace(/\/+$/, "");
|
|
976
|
+
// api/token is control-plane: the CLI mints the build API token itself,
|
|
977
|
+
// and that endpoint's response is a token whose scopes come from the
|
|
978
|
+
// request body, not from the CLI mint — so the write gate cannot see
|
|
979
|
+
// them. Calling it as a data endpoint would hand back a write-capable
|
|
980
|
+
// token without --allow-write. It is not a data method; reject it.
|
|
981
|
+
if (/^api\/token(\/|$)/.test(normalizedPath)) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
"api/token is not a callable SDK endpoint. The CLI mints build API " +
|
|
984
|
+
"tokens internally for sdk calls.",
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
// --path is for endpoints not in the curated list. Reject curated paths
|
|
988
|
+
// so their read/write safety handling cannot be bypassed via --path.
|
|
989
|
+
const curatedMethodNames = SDK_CLI_METHOD_NAMES_BY_PATH.get(normalizedPath);
|
|
990
|
+
if (curatedMethodNames) {
|
|
991
|
+
throw new Error(
|
|
992
|
+
`${normalizedPath} is a curated SDK endpoint. Call it as ` +
|
|
993
|
+
`${curatedMethodNames.join(" or ")} (applies the correct read/` +
|
|
994
|
+
"write handling). --path is only for endpoints not in `lumine sdk list`.",
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
endpoint = { path: normalizedPath, scopes: SDK_CLI_READ_SCOPES };
|
|
998
|
+
} else {
|
|
999
|
+
if (!methodName) {
|
|
1000
|
+
throw new Error(
|
|
1001
|
+
'Usage: lumine sdk call <namespace.method> [jsonArgs]. Run `lumine sdk list`.',
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
endpoint = SDK_CLI_METHODS[methodName];
|
|
1005
|
+
if (!endpoint) {
|
|
1006
|
+
throw new Error(
|
|
1007
|
+
`Unknown SDK method "${methodName}". Run \`lumine sdk list\`, or call ` +
|
|
1008
|
+
"the endpoint directly with --path api/<endpoint>.",
|
|
1009
|
+
);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Token-capability gate: a write-capable build API token must never be
|
|
1014
|
+
// minted (and write-tagged methods never called) without --allow-write,
|
|
1015
|
+
// regardless of whether the call came from the curated table, --path, or a
|
|
1016
|
+
// --scopes override.
|
|
1017
|
+
const requestedScopes = options.sdkScopes.length
|
|
1018
|
+
? options.sdkScopes
|
|
1019
|
+
: endpoint.scopes || [];
|
|
1020
|
+
const writeScopes = requestedScopes.filter(isWriteCapableScope);
|
|
1021
|
+
// writeWhen covers endpoints that mutate under a read scope depending on
|
|
1022
|
+
// their args (e.g. reminders.getDue acknowledging due reminders).
|
|
1023
|
+
const writeByArgs =
|
|
1024
|
+
typeof endpoint.writeWhen === "function" && endpoint.writeWhen(args);
|
|
1025
|
+
const writeCapable =
|
|
1026
|
+
Boolean(endpoint.write) || writeScopes.length > 0 || writeByArgs;
|
|
1027
|
+
if (writeCapable && !options.allowWrite) {
|
|
1028
|
+
const reason = writeScopes.length
|
|
1029
|
+
? `write scopes: ${writeScopes.join(", ")}`
|
|
1030
|
+
: "this call writes app data";
|
|
1031
|
+
throw new Error(
|
|
1032
|
+
`${methodName || endpoint.path} can mutate real app data ` +
|
|
1033
|
+
`(${reason}). Re-run with --allow-write.`,
|
|
1034
|
+
);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Resolve the build target (local-only: --build or workspace metadata)
|
|
1038
|
+
// before any auth side effects, so invalid local input fails immediately
|
|
1039
|
+
// instead of after starting the device-login flow.
|
|
1040
|
+
const buildId = await resolveSdkBuildId(options);
|
|
1041
|
+
const auth = await ensureAuth(options);
|
|
1042
|
+
await assertAuthScope({ options, auth, scope: "build:sdk" });
|
|
1043
|
+
const attempts = [];
|
|
1044
|
+
let lastResult = null;
|
|
1045
|
+
|
|
1046
|
+
if (endpoint.special === "leaderboardGet" || endpoint.special === "leaderboardSubmit") {
|
|
1047
|
+
const boardKey = String(args.boardKey || "default");
|
|
1048
|
+
const rest = { ...args };
|
|
1049
|
+
delete rest.boardKey;
|
|
1050
|
+
for (let attempt = 0; attempt < options.repeat; attempt += 1) {
|
|
1051
|
+
if (endpoint.special === "leaderboardGet") {
|
|
1052
|
+
const url = new URL(
|
|
1053
|
+
`${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}`,
|
|
1054
|
+
);
|
|
1055
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
1056
|
+
if (value !== null && value !== undefined) {
|
|
1057
|
+
url.searchParams.set(key, String(value));
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
lastResult = await executeSdkHttpCall({
|
|
1061
|
+
options,
|
|
1062
|
+
method: "GET",
|
|
1063
|
+
url: url.toString(),
|
|
1064
|
+
authToken: auth.token,
|
|
1065
|
+
});
|
|
1066
|
+
} else {
|
|
1067
|
+
lastResult = await executeSdkHttpCall({
|
|
1068
|
+
options,
|
|
1069
|
+
method: "POST",
|
|
1070
|
+
url: `${options.apiUrl}/build/${buildId}/leaderboards/${encodeURIComponent(boardKey)}/submit`,
|
|
1071
|
+
authToken: auth.token,
|
|
1072
|
+
body: rest,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
attempts.push(lastResult);
|
|
1076
|
+
printSdkAttemptLine(attempts.length, lastResult);
|
|
1077
|
+
}
|
|
1078
|
+
} else if (
|
|
1079
|
+
endpoint.special === "userDbQuery" ||
|
|
1080
|
+
endpoint.special === "userDbExec"
|
|
1081
|
+
) {
|
|
1082
|
+
// viewer-db uses session auth (no build API token). Body is { sql, params }.
|
|
1083
|
+
const route = endpoint.special === "userDbExec" ? "exec" : "query";
|
|
1084
|
+
const body = { sql: args.sql, params: args.params };
|
|
1085
|
+
for (let attempt = 0; attempt < options.repeat; attempt += 1) {
|
|
1086
|
+
lastResult = await executeSdkHttpCall({
|
|
1087
|
+
options,
|
|
1088
|
+
method: "POST",
|
|
1089
|
+
url: `${options.apiUrl}/build/${buildId}/viewer-db/${route}`,
|
|
1090
|
+
authToken: auth.token,
|
|
1091
|
+
body,
|
|
1092
|
+
});
|
|
1093
|
+
attempts.push(lastResult);
|
|
1094
|
+
printSdkAttemptLine(attempts.length, lastResult);
|
|
1095
|
+
}
|
|
1096
|
+
} else {
|
|
1097
|
+
// Always request explicit scopes: an empty scopes body makes the server
|
|
1098
|
+
// fall back to its default scope set, which is write-capable.
|
|
1099
|
+
const scopes = requestedScopes.length
|
|
1100
|
+
? requestedScopes
|
|
1101
|
+
: SDK_CLI_READ_SCOPES;
|
|
1102
|
+
const tokenStartedAt = Date.now();
|
|
1103
|
+
const tokenResult = await requestJson({
|
|
1104
|
+
method: "POST",
|
|
1105
|
+
url: `${options.apiUrl}/build/${buildId}/api/token`,
|
|
1106
|
+
authToken: auth.token,
|
|
1107
|
+
body: { scopes },
|
|
1108
|
+
timeoutMs: options.timeoutMs,
|
|
1109
|
+
});
|
|
1110
|
+
console.error(
|
|
1111
|
+
`token minted in ${Date.now() - tokenStartedAt}ms (scopes: ${
|
|
1112
|
+
(tokenResult.scopes || []).join(", ") || "default"
|
|
1113
|
+
})`,
|
|
1114
|
+
);
|
|
1115
|
+
const body = endpoint.mapArgs ? endpoint.mapArgs(args) : args;
|
|
1116
|
+
for (let attempt = 0; attempt < options.repeat; attempt += 1) {
|
|
1117
|
+
lastResult = await executeSdkHttpCall({
|
|
1118
|
+
options,
|
|
1119
|
+
method: "POST",
|
|
1120
|
+
url: `${options.apiUrl}/build/${buildId}/${endpoint.path}`,
|
|
1121
|
+
authToken: auth.token,
|
|
1122
|
+
body,
|
|
1123
|
+
buildApiToken: tokenResult.token,
|
|
1124
|
+
});
|
|
1125
|
+
attempts.push(lastResult);
|
|
1126
|
+
printSdkAttemptLine(attempts.length, lastResult);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
if (options.repeat > 1) {
|
|
1131
|
+
const timings = attempts.map((entry) => entry.ms);
|
|
1132
|
+
const total = timings.reduce((sum, ms) => sum + ms, 0);
|
|
1133
|
+
console.error(
|
|
1134
|
+
`latency over ${timings.length} calls: min ${Math.min(...timings)}ms · ` +
|
|
1135
|
+
`avg ${Math.round(total / timings.length)}ms · max ${Math.max(...timings)}ms`,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// The printed JSON is the raw endpoint response. Some SDK wrappers reshape
|
|
1140
|
+
// it, so warn precisely where the SDK return shape differs.
|
|
1141
|
+
if (endpoint && endpoint.sdkReshape) {
|
|
1142
|
+
console.error(
|
|
1143
|
+
`note: raw endpoint response below — ${methodName}'s SDK wrapper ` +
|
|
1144
|
+
`reshapes it (${endpoint.sdkReshape}). See TWINKLE_BUILD_SDK.md.`,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
if (lastResult) {
|
|
1148
|
+
console.log(JSON.stringify(lastResult.data ?? lastResult.text, null, 2));
|
|
1149
|
+
}
|
|
1150
|
+
if (lastResult && !lastResult.ok) {
|
|
1151
|
+
process.exitCode = 1;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function printSdkAttemptLine(attemptNumber, result) {
|
|
1156
|
+
console.error(
|
|
1157
|
+
`#${attemptNumber} ${result.status}${result.ok ? " OK" : ""} · ` +
|
|
1158
|
+
`${result.bytes.toLocaleString()} bytes · ${result.ms}ms`,
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async function executeSdkHttpCall({
|
|
1163
|
+
options,
|
|
1164
|
+
method,
|
|
1165
|
+
url,
|
|
1166
|
+
authToken,
|
|
1167
|
+
body,
|
|
1168
|
+
buildApiToken,
|
|
1169
|
+
}) {
|
|
1170
|
+
const startedAt = Date.now();
|
|
1171
|
+
const response = await request({
|
|
1172
|
+
method,
|
|
1173
|
+
url,
|
|
1174
|
+
authToken,
|
|
1175
|
+
body,
|
|
1176
|
+
timeoutMs: options.timeoutMs,
|
|
1177
|
+
headers: buildApiToken ? { "x-build-api-token": buildApiToken } : {},
|
|
1178
|
+
});
|
|
1179
|
+
const text = await response.text();
|
|
1180
|
+
return {
|
|
1181
|
+
ok: response.ok,
|
|
1182
|
+
status: response.status,
|
|
1183
|
+
ms: Date.now() - startedAt,
|
|
1184
|
+
bytes: Buffer.byteLength(text),
|
|
1185
|
+
data: parseJson(text),
|
|
1186
|
+
text,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
async function resolveSdkBuildId(options) {
|
|
1191
|
+
if (options.buildIdFlag) {
|
|
1192
|
+
const buildId = resolveBuildId(options.buildIdFlag);
|
|
1193
|
+
if (buildId > 0) return buildId;
|
|
1194
|
+
throw new Error(`Invalid --build value: ${options.buildIdFlag}`);
|
|
1195
|
+
}
|
|
1196
|
+
const localProject = await findLocalProjectMetadata(
|
|
1197
|
+
options.dir ? path.resolve(options.dir) : process.cwd(),
|
|
1198
|
+
);
|
|
1199
|
+
const buildId = Number(localProject?.metadata?.buildId || 0);
|
|
1200
|
+
if (buildId > 0) return buildId;
|
|
1201
|
+
throw new Error(
|
|
1202
|
+
"No build workspace found. Run from a pulled workspace, pass --dir, or pass --build <id>.",
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
705
1206
|
async function listBuilds({ options, auth }) {
|
|
706
1207
|
const url = new URL(`${options.apiUrl}/cli/builds`);
|
|
707
1208
|
url.searchParams.set("limit", String(options.limit));
|
|
@@ -980,7 +1481,7 @@ async function assertAuthScope({ options, auth, scope }) {
|
|
|
980
1481
|
const scopes = Array.isArray(session.scopes) ? session.scopes : [];
|
|
981
1482
|
if (!scopes.includes(scope)) {
|
|
982
1483
|
throw new Error(
|
|
983
|
-
`Saved login is missing ${scope}. Run \`lumine login\` again to
|
|
1484
|
+
`Saved login is missing ${scope}. Run \`lumine login\` again to grant it.`,
|
|
984
1485
|
);
|
|
985
1486
|
}
|
|
986
1487
|
}
|
|
@@ -1769,7 +2270,14 @@ async function probeUrl({ url, authToken, timeoutMs }) {
|
|
|
1769
2270
|
};
|
|
1770
2271
|
}
|
|
1771
2272
|
|
|
1772
|
-
async function request({
|
|
2273
|
+
async function request({
|
|
2274
|
+
method = "GET",
|
|
2275
|
+
url,
|
|
2276
|
+
authToken,
|
|
2277
|
+
body,
|
|
2278
|
+
timeoutMs,
|
|
2279
|
+
headers = {},
|
|
2280
|
+
}) {
|
|
1773
2281
|
const controller = new AbortController();
|
|
1774
2282
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
1775
2283
|
try {
|
|
@@ -1778,6 +2286,7 @@ async function request({ method = "GET", url, authToken, body, timeoutMs }) {
|
|
|
1778
2286
|
headers: {
|
|
1779
2287
|
...(authToken ? { authorization: authorizationHeader(authToken) } : {}),
|
|
1780
2288
|
...(body ? { "content-type": "application/json" } : {}),
|
|
2289
|
+
...headers,
|
|
1781
2290
|
},
|
|
1782
2291
|
body: body ? JSON.stringify(body) : undefined,
|
|
1783
2292
|
signal: controller.signal,
|
|
@@ -1919,6 +2428,7 @@ function parseArgs(args) {
|
|
|
1919
2428
|
"publish",
|
|
1920
2429
|
"save",
|
|
1921
2430
|
"noUpdateCheck",
|
|
2431
|
+
"allowWrite",
|
|
1922
2432
|
]);
|
|
1923
2433
|
|
|
1924
2434
|
for (let i = 0; i < rest.length; i += 1) {
|
|
@@ -1945,6 +2455,15 @@ function parseArgs(args) {
|
|
|
1945
2455
|
|
|
1946
2456
|
return {
|
|
1947
2457
|
command,
|
|
2458
|
+
positional,
|
|
2459
|
+
repeat: Math.min(Math.max(Math.floor(Number(raw.repeat) || 1), 1), 20),
|
|
2460
|
+
allowWrite: parseBoolean(raw.allowWrite, false),
|
|
2461
|
+
sdkPath: raw.path ? String(raw.path) : "",
|
|
2462
|
+
sdkScopes: String(raw.scopes || "")
|
|
2463
|
+
.split(",")
|
|
2464
|
+
.map((scope) => scope.trim())
|
|
2465
|
+
.filter(Boolean),
|
|
2466
|
+
buildIdFlag: raw.build ? String(raw.build) : "",
|
|
1948
2467
|
target: raw.url || raw.target || positional[0] || "",
|
|
1949
2468
|
title:
|
|
1950
2469
|
String(
|
|
@@ -2279,6 +2798,8 @@ function printHelp() {
|
|
|
2279
2798
|
lumine save
|
|
2280
2799
|
lumine check [twinkle-build-url]
|
|
2281
2800
|
lumine launch [twinkle-build-url]
|
|
2801
|
+
lumine sdk list
|
|
2802
|
+
lumine sdk call <namespace.method> [jsonArgs]
|
|
2282
2803
|
|
|
2283
2804
|
Examples:
|
|
2284
2805
|
npx @stage5/lumine@latest
|
|
@@ -2295,6 +2816,9 @@ Examples:
|
|
|
2295
2816
|
npx @stage5/lumine@latest save --publish
|
|
2296
2817
|
npx @stage5/lumine@latest launch --save
|
|
2297
2818
|
npx @stage5/lumine@latest launch https://www.twin-kle.com/app/123
|
|
2819
|
+
npx @stage5/lumine@latest sdk call aiStories.chapters '{"limit": 5}'
|
|
2820
|
+
npx @stage5/lumine@latest sdk call privateDb.get '{"key": "prefs"}' --build 1374
|
|
2821
|
+
npx @stage5/lumine@latest sdk call aiStories.list '{"difficulty": 1}' --repeat 5
|
|
2298
2822
|
|
|
2299
2823
|
Options:
|
|
2300
2824
|
--api-url <url> Twinkle API origin
|
|
@@ -2313,5 +2837,10 @@ Options:
|
|
|
2313
2837
|
--limit <number> Number of projects to show
|
|
2314
2838
|
--no-update-check Skip the npm latest-version check
|
|
2315
2839
|
--no-open Print the approval URL without opening a browser
|
|
2840
|
+
--build <id> Build id for sdk calls outside a workspace
|
|
2841
|
+
--repeat <n> Repeat an sdk call (1-20) and print latency stats
|
|
2842
|
+
--allow-write Permit sdk methods that mutate app data
|
|
2843
|
+
--path <api/...> Call an sdk endpoint not in the curated list
|
|
2844
|
+
--scopes <a,b> Override requested build API token scopes
|
|
2316
2845
|
`);
|
|
2317
2846
|
}
|
package/package.json
CHANGED
package/sdk/BUILD_SDK_INDEX.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Build SDK Index
|
|
2
2
|
|
|
3
|
-
Version: 1.26.
|
|
4
|
-
Updated: 2026-06-
|
|
5
|
-
Generated: 2026-06-
|
|
3
|
+
Version: 1.26.2
|
|
4
|
+
Updated: 2026-06-09
|
|
5
|
+
Generated: 2026-06-09T01:00:18.637Z
|
|
6
6
|
|
|
7
7
|
## Notes
|
|
8
8
|
- This SDK is injected into Build iframes via the Build preview/runtime.
|
|
@@ -17,7 +17,7 @@ Generated: 2026-06-08T05:37:13.752Z
|
|
|
17
17
|
- Use Twinkle.aiStories for read-only existing AI Story galleries, readers, quizzes, topic chapter indexes, and remix tools.
|
|
18
18
|
- Use Twinkle.grammarbles for public Grammarbles question-bank trainer apps and optional signed-in viewer attempt-history filtering.
|
|
19
19
|
- Use Twinkle.chess for chess engine play and analysis; app code still owns chess rules, legal moves, board state, and UI.
|
|
20
|
-
- Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions;
|
|
20
|
+
- Use Twinkle.world for realtime multiplayer rooms, avatar presence, movement, emotes, and lightweight actions; world sessions are disposable and durable MMO state belongs in sharedDb/privateDb.
|
|
21
21
|
- Use Twinkle.characters.chat for real Zero/Ciel NPC dialogue with shared room context and AI Energy-aware thinking modes.
|
|
22
22
|
- Twinkle.ai.chat history entries must use { role, content }; map local message.text fields to content before passing history.
|
|
23
23
|
|
|
@@ -316,6 +316,28 @@ const result = await Twinkle.characters.chat({ character: 'zero', thinkingMode:
|
|
|
316
316
|
- Example: const world = await Twinkle.world.join({ roomKey: 'town-square', presence: { x: 0, y: 0, z: 0, facing: 'south' }, player: { name: avatarName } });
|
|
317
317
|
world.subscribe((event) => updateRemotePlayers(event.players));
|
|
318
318
|
world.updatePresence({ x, y, z, facing });
|
|
319
|
+
- isRecoverableSessionError(error) | scopes: none
|
|
320
|
+
- Returns: boolean
|
|
321
|
+
- Return true when a world request error is expected to be handled by app code instead of crashing.
|
|
322
|
+
- Example: try {
|
|
323
|
+
await world.updatePresence({ x, y, z, facing });
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (Twinkle.world.isSessionEndedError(error)) {
|
|
326
|
+
world = null;
|
|
327
|
+
scheduleReconnect();
|
|
328
|
+
} else if (Twinkle.world.isRecoverableSessionError(error)) {
|
|
329
|
+
// Drop this transient presence update and keep the current handle.
|
|
330
|
+
} else {
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
- isSessionEndedError(error) | scopes: none
|
|
335
|
+
- Returns: boolean
|
|
336
|
+
- Return true when a world request error means the current session handle is stale and app code should reconnect with a fresh Twinkle.world.join call.
|
|
337
|
+
- Example: if (Twinkle.world.isSessionEndedError(error)) {
|
|
338
|
+
world = null;
|
|
339
|
+
scheduleReconnect();
|
|
340
|
+
}
|
|
319
341
|
- leaveAll() | scopes: none
|
|
320
342
|
- Returns: void
|
|
321
343
|
- Leave every active world session in the current iframe.
|
|
@@ -542,27 +564,64 @@ await Twinkle.chat.sendMessage('lobby', 'hello');
|
|
|
542
564
|
```
|
|
543
565
|
|
|
544
566
|
### Realtime MMO town room
|
|
545
|
-
Use Twinkle.world for live avatar presence and lightweight room actions,
|
|
567
|
+
Use Twinkle.world for live avatar presence and lightweight room actions, recover stale session handles, and keep durable state like inventory and quests in sharedDb/privateDb.
|
|
546
568
|
Keywords: multiplayer, mmo, town, presence, avatars, movement, three.js, realtime
|
|
547
569
|
|
|
548
570
|
```js
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
571
|
+
let world = null;
|
|
572
|
+
let reconnectTimer = 0;
|
|
573
|
+
|
|
574
|
+
async function connectWorld() {
|
|
575
|
+
if (world) return world;
|
|
576
|
+
world = await Twinkle.world.join({
|
|
577
|
+
worldKey: 'town',
|
|
578
|
+
roomKey: 'square',
|
|
579
|
+
presence: { x: 0, y: 0, z: 0, facing: 'south', animation: 'idle' },
|
|
580
|
+
player: { name: avatarName }
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
world.subscribe((event) => {
|
|
584
|
+
renderPlayers(event.players);
|
|
585
|
+
if (event.type === 'session.ended') {
|
|
586
|
+
handleWorldDrop();
|
|
587
|
+
}
|
|
588
|
+
if (event.type === 'action.received' && event.action?.type === 'emote') {
|
|
589
|
+
showEmote(event.sessionId, event.action.data.emote);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
return world;
|
|
593
|
+
}
|
|
555
594
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (
|
|
559
|
-
|
|
595
|
+
function handleWorldDrop() {
|
|
596
|
+
world = null;
|
|
597
|
+
if (!reconnectTimer) {
|
|
598
|
+
reconnectTimer = setTimeout(() => {
|
|
599
|
+
reconnectTimer = 0;
|
|
600
|
+
connectWorld().catch(handleWorldDrop);
|
|
601
|
+
}, 1000);
|
|
560
602
|
}
|
|
561
|
-
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function syncPresence() {
|
|
606
|
+
try {
|
|
607
|
+
const session = await connectWorld();
|
|
608
|
+
// Throttle this in the game loop, for example 5-15 times per second.
|
|
609
|
+
await session.updatePresence({ x: player.x, y: player.y, z: player.z, facing });
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (Twinkle.world.isSessionEndedError(error)) {
|
|
612
|
+
handleWorldDrop();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
if (Twinkle.world.isRecoverableSessionError(error)) {
|
|
616
|
+
// Drop this transient presence update and keep the current handle.
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
562
622
|
|
|
563
|
-
|
|
564
|
-
await
|
|
565
|
-
await world.send('emote', { emote: 'wave' });
|
|
623
|
+
await connectWorld();
|
|
624
|
+
await syncPresence();
|
|
566
625
|
```
|
|
567
626
|
|
|
568
627
|
### Play chess against the computer
|