lowlander 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.
@@ -0,0 +1,83 @@
1
+ import 'mdui/mdui.css';
2
+ import 'mdui/components/button.js';
3
+ import 'mdui/components/fab.js';
4
+ import 'mdui/components/text-field.js';
5
+ import { alert } from 'mdui/functions/alert.js';
6
+ import type * as API from '../../server/api.js';
7
+ import { Connection } from 'lowlander/client';
8
+ import A from 'aberdeen';
9
+
10
+ import { setColorScheme } from 'mdui/functions/setColorScheme.js';
11
+ // Generate a color scheme based on #0061a4 and set the <html> element to that color scheme
12
+ setColorScheme('#ef6b00');
13
+
14
+ A.setSpacingCssVars();
15
+
16
+ const WEBSOCKET_URL = `ws://${location.hostname}:8080/`;
17
+
18
+ // Create a WebSocket connection with type-safe RPC to the server API.
19
+ const connection = new Connection<typeof API>(WEBSOCKET_URL);
20
+ const api = connection.api;
21
+ A('h2#Online')
22
+ // Create a reactive scope to render online status
23
+ A(() => {
24
+ A(connection.isOnline() ? 'text=yes' : 'text=no');
25
+ });
26
+
27
+ // Simple RPC call - returns a PromiseProxy that resolves to the result.
28
+ const sum = api.add(1234, 6753);
29
+ A('h2#Answer')
30
+ A.dump(sum);
31
+
32
+ // Server proxy example - authenticate returns both a value and a stateful API object.
33
+ const auth = api.authenticate('Frank');
34
+ A('h2#AuthToken');
35
+ A.dump(auth); // auth.value will become "secret" once authentication completes
36
+
37
+
38
+ // Access the server-side UserAPI through the .serverProxy property.
39
+ // Note that this proxy is usable immediately, even though the authentication
40
+ // call is still in progress - the server will process requests in-order.
41
+ // If authentication were to fail, so would any calls on the server proxy.
42
+ const userApi = auth.serverProxy;
43
+
44
+ // Call methods on the server proxy (returns PromiseProxy like regular RPC).
45
+ const bio = userApi.getBio();
46
+ A('h2#Bio');
47
+ A.dump(bio); // bio.value will become "1:Frank"
48
+
49
+ // Model streaming - returns a reactive proxy that updates when server-side model changes.
50
+ const model = api.streamModel();
51
+ A('h2#Model');
52
+ A.dump(model); // Live model (and nested linked models) will appear in model.value
53
+
54
+ A('h2#Toggle friend');
55
+ const formBusy = A.proxy(false);
56
+ const friendName = A.proxy('');
57
+ A('form display:flex gap:$2', 'submit=', async (e: any) => {
58
+ e.preventDefault();
59
+ formBusy.value = true;
60
+ const found = await userApi.toggleFriend(friendName.value).promise;
61
+ formBusy.value = false;
62
+ if (!found) alert({description: 'No such person: ' + friendName.value});
63
+ }, () => {
64
+ A('mdui-text-field label="Person name" bind=', friendName);
65
+ A(() => {
66
+ if (formBusy.value) A('mdui-circular-progress');
67
+ else A('mdui-button type=submit #Toggle friend');
68
+ })
69
+ });
70
+
71
+
72
+
73
+ // Socket streaming - server pushes data via callbacks.
74
+ const data = A.proxy([] as number[]);
75
+ let dataIndex = 0;
76
+ api.streamSomething(item => data[dataIndex++ % 20] = item);
77
+ A('h2#Streamed data');
78
+ A.dump(data);
79
+
80
+ A('mdui-fab icon=admin_panel_settings text="Lowlander Admin" click=', async () => {
81
+ const { showAdminModal } = await import('./admin');
82
+ showAdminModal(api);
83
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "helloworld",
3
+ "private": true,
4
+ "dependencies": {
5
+ "lowlander": "file:../../",
6
+ "mdui": "^2.1.4"
7
+ }
8
+ }
@@ -0,0 +1,154 @@
1
+ import * as E from "edinburgh";
2
+ import { ServerProxy, createStreamType, Socket } from "lowlander/server";
3
+ import * as warpsocket from "warpsocket";
4
+
5
+ export const getDebugState = warpsocket.getDebugState;
6
+
7
+ // Simple RPC function example
8
+ export function add(a: number, b: number): number {
9
+ return a + b;
10
+ }
11
+
12
+ // Example of a stateful server-side API that's exposed via ServerProxy
13
+ export class UserAPI {
14
+ constructor(public userName: string) {}
15
+
16
+ get user(): Person {
17
+ const result = Person.byName.get(this.userName);
18
+ if (!result) throw new Error(`User '${this.userName}' not found`);
19
+ return result;
20
+ }
21
+
22
+ getBio() {
23
+ return `${this.user.name} is ${this.user.age} years old and has ${this.user.friends.length} friend(s).`;
24
+ }
25
+
26
+ toggleFriend(friendName: string) {
27
+ for(const [idx, val] of Object.entries(this.user.friends)) {
28
+ if (val.name === friendName) {
29
+ this.user.friends.splice(Number(idx), 1);
30
+ return true;
31
+ }
32
+ }
33
+ const friend = Person.byName.get(friendName);
34
+ if (!friend) return false;
35
+ this.user.friends.push(friend);
36
+ return true;
37
+ }
38
+
39
+ // admin() {
40
+ // if (this.user.name !== 'Frank') {
41
+ // throw new Error('Access denied');
42
+ // }
43
+ // return new ServerProxy(admin);
44
+ // }
45
+ }
46
+
47
+ // Authentication example - returns a ServerProxy with both a value and API object
48
+ export async function authenticate(auth: string) {
49
+ await new Promise(resolve => setTimeout(resolve, 1000));
50
+ const user = Person.byName.get(auth);
51
+ if (!user) throw new Error('User not found');
52
+ // Client receives 'secret' as .value and UserAPI methods via .serverProxy
53
+ return new ServerProxy(new UserAPI(auth), 'secret');
54
+ }
55
+
56
+
57
+ // Edinburgh model definitions
58
+ @E.registerModel
59
+ class Person extends E.Model<Person> {
60
+ static byName = E.primary(Person, 'name');
61
+ name = E.field(E.string);
62
+ age = E.field(E.number);
63
+ friends = E.field(E.array(E.link(Person)));
64
+ password = E.field(E.string);
65
+ }
66
+
67
+ @E.registerModel
68
+ class MyModel extends E.Model<MyModel> {
69
+ id = E.field(E.identifier);
70
+ name = E.field(E.string);
71
+ next = E.field(E.opt(E.link(MyModel)));
72
+ owner = E.field(E.link(Person));
73
+ createdAt = E.field(E.dateTime);
74
+
75
+ static byId = E.primary(MyModel, 'id');
76
+ static byName = E.unique(MyModel, 'name');
77
+ }
78
+
79
+ let ids: {p1: string, p2: string, m1: string, m2: string};
80
+ export async function resetTestData(deleteEverything: boolean) {
81
+ if (deleteEverything) {
82
+ await E.deleteEverything();
83
+ }
84
+
85
+ // Initialize some test data. Even if we are already running in a transaction, we need to do this
86
+ // in a new (nested) transaction, as deleteEverything will have done *its* work in separate transactions
87
+ // as well, and we need access to its results.
88
+ await E.transact(() => {
89
+ let p1 = Person.byName.get('Frank') || new Person({name: 'Frank', age: 45, password: 'secret'});
90
+ let p2 = Person.byName.get('Alice') || new Person({name: 'Alice', age: 25, password: 'hidden', friends: [p1]});
91
+ let p3 = Person.byName.get('Bob') || new Person({name: 'Bob', age: 65, password: 'himom', friends: [p1, p2]});
92
+ if (p1.getState() === "created") p1.friends = [p2, p3];
93
+ let m1 = MyModel.byName.get('Test') || new MyModel({name: 'Test', owner: p1});
94
+ let m2 = MyModel.byName.get('Another') || new MyModel({name: 'Another', owner: p2, next: m1});
95
+ ids = {p1: p1.name, p2: p2.name, m1: m1.id, m2: m2.id};
96
+ });
97
+ }
98
+ resetTestData(false);
99
+
100
+ await E.transact(() => {
101
+ E.dump();
102
+ for(const p of Person.findAll()) {
103
+ console.log('Person:', p.name, 'age', p.age, 'friends', p.friends.map(f => f.name).join(','), 'password', p.password);
104
+ }
105
+ });
106
+
107
+
108
+ // Create a stream type that specifies which fields to send to clients
109
+ // Note: password is excluded for security, and we include nested linked model fields
110
+ const MyStream = createStreamType(MyModel, {
111
+ name: true,
112
+ createdAt: true,
113
+ owner: {
114
+ name: true,
115
+ age: true,
116
+ friends: {
117
+ name: true,
118
+ age: true,
119
+ }
120
+ }
121
+ });
122
+
123
+ // Example of model streaming - returns a reactive proxy that auto-updates on changes
124
+ export function streamModel() {
125
+ const m1 = MyModel.byId.get(ids.m1)!;
126
+ return new MyStream(m1);
127
+ }
128
+
129
+ export async function incrOwnerAge(delta: number) {
130
+ const m1 = MyModel.byId.get(ids.m1)!;
131
+ const current = m1.owner.age;
132
+ await new Promise(resolve => setTimeout(resolve, 50));
133
+ m1.owner.age = current + delta;
134
+ }
135
+
136
+ export function setOwnerAge(age: number) {
137
+ const m1 = MyModel.byId.get(ids.m1)!;
138
+ m1.owner.age = age;
139
+ }
140
+
141
+
142
+ // Example of server-push streaming via Socket callback
143
+ // Client provides a callback; server pushes data by calling socket.send()
144
+ export function streamSomething(socket: Socket<number>) {
145
+ let interval = setInterval(() => {
146
+ console.log('Sending ping');
147
+ // socket.send() returns false when client disconnects
148
+ if (!socket.send(Math.random())) {
149
+ console.log('Socket is not open');
150
+ clearInterval(interval);
151
+ }
152
+ }, 2000);
153
+ }
154
+
@@ -0,0 +1,27 @@
1
+ // This example is Bun-only, as it conveniently transpiles client-files on the fly.
2
+ // For Node.js, you'd have to add a build step for client-files, and serve them as
3
+ // static files (e.g. using express.static).
4
+
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, resolve } from 'path';
7
+ import * as lowlander from "lowlander/server";
8
+ import index from "../client/index.html";
9
+
10
+ const apiFile = resolve(dirname(fileURLToPath(import.meta.url)), 'api.js');
11
+
12
+ // Start the WebSocket server with the given API handler
13
+ lowlander.start(apiFile, {threads: 1});
14
+
15
+ // Serve index.html on /
16
+ Bun.serve({
17
+ port: process.env.PORT || 3000,
18
+ routes: {
19
+ '/': index,
20
+ },
21
+ development: true,
22
+ });
23
+
24
+
25
+ // We're only doing this import here such that Bun knows to reload when api.ts changes
26
+ // when in --watch mode.
27
+ import './api';
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "lowlander",
3
+ "version": "0.2.0",
4
+ "description": "TypeScript framework for data persistence, type-safe RPCs and real-time (partial) client synchronization.",
5
+ "type": "module",
6
+ "main": "./build/server/server.js",
7
+ "browser": "./build/client/client.js",
8
+ "types": "./build/server/server.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "bun": "./server/server.ts",
12
+ "import": "./build/server/server.js",
13
+ "types": "./build/server/server.d.ts"
14
+ },
15
+ "./client": {
16
+ "bun": "./client/client.ts",
17
+ "import": "./build/client/client.js",
18
+ "types": "./build/client/client.d.ts"
19
+ },
20
+ "./server": {
21
+ "bun": "./server/server.ts",
22
+ "import": "./build/server/server.js",
23
+ "types": "./build/server/server.d.ts"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -b && cp -rf examples/helloworld/client/assets examples/helloworld/client/index.html build/examples/helloworld/client/ && npm run build:readme",
28
+ "typecheck": "bun x tsc -b",
29
+ "clean": "rm -rf build dist",
30
+ "build:readme": "readme-tsdoc --repo-url https://github.com/vanviegen/edinburgh",
31
+ "test": "bun test"
32
+ },
33
+ "dependencies": {
34
+ "warpsocket": "^0.8.1"
35
+ },
36
+ "peerDependencies": {
37
+ "edinburgh": "^0.4.1",
38
+ "aberdeen": "^1.10.1"
39
+ },
40
+ "devDependencies": {
41
+ "aberdeen": "^1.10.1",
42
+ "readme-tsdoc": "^1.1.4",
43
+ "@types/bun": "^1.3.10"
44
+ },
45
+ "workspaces": [
46
+ "examples/*"
47
+ ],
48
+ "author": "Frank van Viegen BV",
49
+ "license": "ISC"
50
+ }
@@ -0,0 +1,22 @@
1
+ // requestId + callbackIndex + callback arguments
2
+
3
+ // or
4
+
5
+ // requestId + one of these types:
6
+ export const SERVER_MESSAGES = {
7
+ error: 'e', // followed by errorMessage
8
+ response: 'r', // followed by result + virtualSocketIds
9
+ response_proxy: 'p', // followed by result + virtualSocketIds (like above, but indicate that a ServerProxy has been created for this request)
10
+ response_model: 'm', // followed by virtualSocketIds + dbKey
11
+ model_data: 'd', // followed by dbKey + commitId + delta
12
+ };
13
+
14
+ // The virtualSocketIds in a response or response_model is stored by the client and must
15
+ // be provided to 'cancel'.
16
+ // dbKey is a stochastically unique identifier for a subset of a model instance, defined as primaryKeyHash + steamType.id
17
+
18
+ // requestId + one of these types:
19
+ export const CLIENT_MESSAGES = {
20
+ call: 1, // followed by proxyId/undefined + methodName + params + ...
21
+ cancel: 2, // followed by cancelRequestId + virtualSocketIds/undefined
22
+ };