@hypen-space/core 0.4.31 → 0.4.35
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/app.d.ts +73 -2
- package/dist/app.js +112 -9
- package/dist/app.js.map +6 -5
- package/dist/components/builtin.js +112 -9
- package/dist/components/builtin.js.map +6 -5
- package/dist/context.d.ts +8 -0
- package/dist/context.js +6 -2
- package/dist/context.js.map +3 -3
- package/dist/datasource.d.ts +168 -0
- package/dist/discovery.js +114 -9
- package/dist/discovery.js.map +7 -6
- package/dist/disposable.js +6 -2
- package/dist/disposable.js.map +2 -2
- package/dist/engine.browser.d.ts +15 -0
- package/dist/engine.browser.js +19 -2
- package/dist/engine.browser.js.map +3 -3
- package/dist/engine.d.ts +22 -0
- package/dist/engine.js +37 -3
- package/dist/engine.js.map +3 -3
- package/dist/events.js +6 -2
- package/dist/events.js.map +2 -2
- package/dist/index.browser.js +295 -179
- package/dist/index.browser.js.map +9 -8
- package/dist/index.d.ts +3 -1
- package/dist/index.js +298 -179
- package/dist/index.js.map +10 -9
- package/dist/loader.js +6 -2
- package/dist/loader.js.map +2 -2
- package/dist/logger.js +6 -2
- package/dist/logger.js.map +2 -2
- package/dist/plugin.js +6 -2
- package/dist/plugin.js.map +2 -2
- package/dist/remote/client.js +6 -2
- package/dist/remote/client.js.map +2 -2
- package/dist/remote/index.js +152 -11
- package/dist/remote/index.js.map +9 -8
- package/dist/remote/server.js +152 -11
- package/dist/remote/server.js.map +9 -8
- package/dist/renderer.js +6 -2
- package/dist/renderer.js.map +2 -2
- package/dist/resolver.js +6 -2
- package/dist/resolver.js.map +2 -2
- package/dist/router.d.ts +9 -0
- package/dist/router.js +186 -18
- package/dist/router.js.map +6 -5
- package/dist/state.js +13 -6
- package/dist/state.js.map +3 -3
- package/package.json +1 -1
- package/src/app.ts +121 -4
- package/src/context.ts +10 -2
- package/src/datasource.ts +252 -0
- package/src/discovery.ts +2 -0
- package/src/engine.browser.ts +30 -0
- package/src/engine.ts +56 -1
- package/src/index.ts +16 -0
- package/src/managed-router.ts +3 -0
- package/src/remote/server.ts +11 -2
- package/src/router.ts +36 -10
- package/src/state.ts +10 -3
- package/wasm-browser/README.md +10 -10
- package/wasm-browser/hypen_engine.d.ts +368 -131
- package/wasm-browser/hypen_engine.js +896 -645
- package/wasm-browser/hypen_engine_bg.wasm +0 -0
- package/wasm-browser/hypen_engine_bg.wasm.d.ts +20 -13
- package/wasm-browser/package.json +1 -1
- package/wasm-node/README.md +10 -10
- package/wasm-node/hypen_engine.d.ts +318 -89
- package/wasm-node/hypen_engine.js +832 -613
- package/wasm-node/hypen_engine_bg.wasm +0 -0
- package/wasm-node/hypen_engine_bg.wasm.d.ts +20 -13
- package/wasm-node/package.json +1 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Source Plugin System for Hypen
|
|
3
|
+
*
|
|
4
|
+
* Enables live subscription databases (SpacetimeDB, Firebase, Convex, Electric SQL)
|
|
5
|
+
* to push data into the Hypen reactive engine via a unified plugin interface.
|
|
6
|
+
*
|
|
7
|
+
* UI bindings use the `$provider.path` syntax:
|
|
8
|
+
* - `$spacetime.messages` → SpacetimeDB messages table
|
|
9
|
+
* - `$firebase.user.profile` → Firebase user profile
|
|
10
|
+
* - `$convex.tasks` → Convex tasks table
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
/** Connection status for a data source provider */
|
|
15
|
+
export type DataSourceStatus = "disconnected" | "connecting" | "connected" | "reconnecting" | "error";
|
|
16
|
+
/** Describes a subscription to a table or query */
|
|
17
|
+
export interface DataSourceQuery {
|
|
18
|
+
/** Table or collection name (e.g., "messages", "users") */
|
|
19
|
+
table: string;
|
|
20
|
+
/** Optional filter criteria */
|
|
21
|
+
filter?: Record<string, unknown>;
|
|
22
|
+
/** Optional raw query string (e.g., SQL for SpacetimeDB) */
|
|
23
|
+
sql?: string;
|
|
24
|
+
}
|
|
25
|
+
/** Handle returned by subscribe() — call unsubscribe() to stop receiving updates */
|
|
26
|
+
export interface DataSourceSubscription {
|
|
27
|
+
/** Stop receiving updates for this subscription */
|
|
28
|
+
unsubscribe(): void;
|
|
29
|
+
/** Current subscription status */
|
|
30
|
+
status: "pending" | "applied" | "error";
|
|
31
|
+
/** Error details if status is "error" */
|
|
32
|
+
error?: Error;
|
|
33
|
+
}
|
|
34
|
+
/** Describes a batch of changes pushed by a plugin */
|
|
35
|
+
export interface DataSourceChange {
|
|
36
|
+
/** Changed paths relative to the provider root (e.g., ["messages", "users"]) */
|
|
37
|
+
paths: string[];
|
|
38
|
+
/** New values at those paths */
|
|
39
|
+
values: Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Interface that all data source plugins must implement.
|
|
43
|
+
*
|
|
44
|
+
* A plugin manages the connection lifecycle and translates database-specific
|
|
45
|
+
* events (row inserts, updates, deletes) into `DataSourceChange` objects that
|
|
46
|
+
* the Hypen engine can consume.
|
|
47
|
+
*
|
|
48
|
+
* @typeParam TConfig - Plugin-specific configuration type
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* class SpacetimeDBPlugin implements DataSourcePlugin<SpacetimeDBConfig> {
|
|
53
|
+
* readonly name = "spacetime";
|
|
54
|
+
* status: DataSourceStatus = "disconnected";
|
|
55
|
+
*
|
|
56
|
+
* async connect(config: SpacetimeDBConfig, onChange: (change: DataSourceChange) => void) {
|
|
57
|
+
* // Connect to SpacetimeDB, subscribe to tables, push changes via onChange
|
|
58
|
+
* }
|
|
59
|
+
* // ...
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export interface DataSourcePlugin<TConfig = unknown> {
|
|
64
|
+
/** Unique provider name (e.g., "spacetime", "firebase", "convex") */
|
|
65
|
+
readonly name: string;
|
|
66
|
+
/** Current connection status */
|
|
67
|
+
status: DataSourceStatus;
|
|
68
|
+
/**
|
|
69
|
+
* Connect to the data source and begin pushing changes.
|
|
70
|
+
*
|
|
71
|
+
* @param config - Plugin-specific configuration
|
|
72
|
+
* @param onChange - Callback to push data changes into the engine.
|
|
73
|
+
* The plugin MUST call this whenever subscribed data changes.
|
|
74
|
+
*/
|
|
75
|
+
connect(config: TConfig, onChange: (change: DataSourceChange) => void): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Subscribe to a specific table or query.
|
|
78
|
+
* Returns a handle to unsubscribe later.
|
|
79
|
+
*/
|
|
80
|
+
subscribe(query: DataSourceQuery): DataSourceSubscription;
|
|
81
|
+
/**
|
|
82
|
+
* Call a remote procedure / reducer / mutation.
|
|
83
|
+
*
|
|
84
|
+
* @param method - Method name (e.g., "sendMessage" for SpacetimeDB reducer)
|
|
85
|
+
* @param args - Arguments to pass
|
|
86
|
+
* @returns The result from the remote call
|
|
87
|
+
*/
|
|
88
|
+
call(method: string, ...args: unknown[]): Promise<unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* Disconnect from the data source and clean up all subscriptions.
|
|
91
|
+
*/
|
|
92
|
+
disconnect(): Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
/** Interface for the engine's data source operations (maps to WASM setContext/removeContext) */
|
|
95
|
+
export interface IDataSourceEngine {
|
|
96
|
+
/**
|
|
97
|
+
* Set (or replace) a named data source context.
|
|
98
|
+
* Registers the provider, stores the data, and re-renders bound nodes.
|
|
99
|
+
* Sparse merging should happen at the SDK layer before calling this.
|
|
100
|
+
*/
|
|
101
|
+
setContext(name: string, data: Record<string, unknown>): void;
|
|
102
|
+
/**
|
|
103
|
+
* Remove a data source context entirely.
|
|
104
|
+
* Drops the provider's state and re-renders bound nodes (they resolve to null).
|
|
105
|
+
*/
|
|
106
|
+
removeContext(name: string): void;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Manages data source plugin lifecycles and bridges them to the Hypen engine.
|
|
110
|
+
*
|
|
111
|
+
* The manager:
|
|
112
|
+
* 1. Registers plugins with the engine (creates state slots)
|
|
113
|
+
* 2. Connects plugins and wires their onChange callbacks to engine updates
|
|
114
|
+
* 3. Manages subscription lifecycles
|
|
115
|
+
* 4. Handles cleanup on disconnect
|
|
116
|
+
*
|
|
117
|
+
* @example
|
|
118
|
+
* ```typescript
|
|
119
|
+
* const manager = new DataSourceManager(engine);
|
|
120
|
+
*
|
|
121
|
+
* // Register and connect a SpacetimeDB plugin
|
|
122
|
+
* await manager.use(new SpacetimeDBPlugin(), {
|
|
123
|
+
* uri: "ws://localhost:3000",
|
|
124
|
+
* moduleName: "chat",
|
|
125
|
+
* tables: ["user", "message"],
|
|
126
|
+
* });
|
|
127
|
+
*
|
|
128
|
+
* // Access plugin for mutations
|
|
129
|
+
* await manager.get("spacetime").call("sendMessage", "Hello!");
|
|
130
|
+
*
|
|
131
|
+
* // Clean up
|
|
132
|
+
* await manager.disconnectAll();
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export declare class DataSourceManager {
|
|
136
|
+
private plugins;
|
|
137
|
+
private configs;
|
|
138
|
+
/** Accumulated state per provider — sparse merging happens here before setContext */
|
|
139
|
+
private state;
|
|
140
|
+
private engine;
|
|
141
|
+
constructor(engine: IDataSourceEngine);
|
|
142
|
+
/**
|
|
143
|
+
* Register, connect, and start a data source plugin.
|
|
144
|
+
*
|
|
145
|
+
* @param plugin - The plugin instance
|
|
146
|
+
* @param config - Plugin-specific configuration
|
|
147
|
+
*/
|
|
148
|
+
use<C>(plugin: DataSourcePlugin<C>, config: C): Promise<void>;
|
|
149
|
+
/**
|
|
150
|
+
* Get a registered plugin by name.
|
|
151
|
+
* Useful for calling mutations: `manager.get("spacetime").call("sendMessage", text)`
|
|
152
|
+
*/
|
|
153
|
+
get<T extends DataSourcePlugin = DataSourcePlugin>(name: string): T | undefined;
|
|
154
|
+
/** Check if a plugin is registered */
|
|
155
|
+
has(name: string): boolean;
|
|
156
|
+
/** Get all registered provider names */
|
|
157
|
+
getNames(): string[];
|
|
158
|
+
/** Get the status of a specific plugin */
|
|
159
|
+
getStatus(name: string): DataSourceStatus | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Disconnect and remove a specific plugin.
|
|
162
|
+
*/
|
|
163
|
+
remove(name: string): Promise<void>;
|
|
164
|
+
/**
|
|
165
|
+
* Disconnect all plugins and clean up.
|
|
166
|
+
*/
|
|
167
|
+
disconnectAll(): Promise<void>;
|
|
168
|
+
}
|
package/dist/discovery.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
3
7
|
var __export = (target, all) => {
|
|
4
8
|
for (var name in all)
|
|
5
9
|
__defProp(target, name, {
|
|
6
10
|
get: all[name],
|
|
7
11
|
enumerable: true,
|
|
8
12
|
configurable: true,
|
|
9
|
-
set: (
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
10
14
|
});
|
|
11
15
|
};
|
|
12
16
|
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
@@ -381,6 +385,59 @@ var init_result = __esm(() => {
|
|
|
381
385
|
};
|
|
382
386
|
});
|
|
383
387
|
|
|
388
|
+
// src/datasource.ts
|
|
389
|
+
class DataSourceManager {
|
|
390
|
+
plugins = new Map;
|
|
391
|
+
configs = new Map;
|
|
392
|
+
state = new Map;
|
|
393
|
+
engine;
|
|
394
|
+
constructor(engine) {
|
|
395
|
+
this.engine = engine;
|
|
396
|
+
}
|
|
397
|
+
async use(plugin, config2) {
|
|
398
|
+
const { name } = plugin;
|
|
399
|
+
this.state.set(name, {});
|
|
400
|
+
this.plugins.set(name, plugin);
|
|
401
|
+
this.configs.set(name, config2);
|
|
402
|
+
await plugin.connect(config2, (change) => {
|
|
403
|
+
const current = this.state.get(name) ?? {};
|
|
404
|
+
for (const path of change.paths) {
|
|
405
|
+
if (path in change.values) {
|
|
406
|
+
current[path] = change.values[path];
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
this.state.set(name, current);
|
|
410
|
+
this.engine.setContext(name, current);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
get(name) {
|
|
414
|
+
return this.plugins.get(name);
|
|
415
|
+
}
|
|
416
|
+
has(name) {
|
|
417
|
+
return this.plugins.has(name);
|
|
418
|
+
}
|
|
419
|
+
getNames() {
|
|
420
|
+
return Array.from(this.plugins.keys());
|
|
421
|
+
}
|
|
422
|
+
getStatus(name) {
|
|
423
|
+
return this.plugins.get(name)?.status;
|
|
424
|
+
}
|
|
425
|
+
async remove(name) {
|
|
426
|
+
const plugin = this.plugins.get(name);
|
|
427
|
+
if (plugin) {
|
|
428
|
+
await plugin.disconnect();
|
|
429
|
+
this.plugins.delete(name);
|
|
430
|
+
this.configs.delete(name);
|
|
431
|
+
this.state.delete(name);
|
|
432
|
+
this.engine.removeContext(name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async disconnectAll() {
|
|
436
|
+
const names = Array.from(this.plugins.keys());
|
|
437
|
+
await Promise.all(names.map((name) => this.remove(name)));
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
384
441
|
// src/state.ts
|
|
385
442
|
function deepClone(obj) {
|
|
386
443
|
if (obj === null || typeof obj !== "object") {
|
|
@@ -520,9 +577,11 @@ function createObservableState(initialState, options) {
|
|
|
520
577
|
notificationPending = false;
|
|
521
578
|
if (batchDepth === 0) {
|
|
522
579
|
notifyChange();
|
|
523
|
-
}
|
|
580
|
+
} else {}
|
|
524
581
|
});
|
|
525
582
|
}
|
|
583
|
+
} else {
|
|
584
|
+
pendingChange = pendingChange || { paths: [], newValues: {} };
|
|
526
585
|
}
|
|
527
586
|
}
|
|
528
587
|
const proxyCache = new WeakMap;
|
|
@@ -581,11 +640,12 @@ function createObservableState(initialState, options) {
|
|
|
581
640
|
return true;
|
|
582
641
|
},
|
|
583
642
|
deleteProperty(obj, prop) {
|
|
584
|
-
|
|
585
|
-
|
|
643
|
+
const existed = Object.prototype.hasOwnProperty.call(obj, prop);
|
|
644
|
+
const result = delete obj[prop];
|
|
645
|
+
if (existed) {
|
|
586
646
|
scheduleBatch();
|
|
587
647
|
}
|
|
588
|
-
return
|
|
648
|
+
return result;
|
|
589
649
|
}
|
|
590
650
|
});
|
|
591
651
|
proxyCache.set(target, proxy);
|
|
@@ -650,6 +710,7 @@ class HypenAppBuilder {
|
|
|
650
710
|
errorHandler;
|
|
651
711
|
template;
|
|
652
712
|
_registry;
|
|
713
|
+
dataSourceEntries = [];
|
|
653
714
|
constructor(initialState, options, registry) {
|
|
654
715
|
this.initialState = initialState;
|
|
655
716
|
this.options = options || {};
|
|
@@ -683,10 +744,19 @@ class HypenAppBuilder {
|
|
|
683
744
|
this.errorHandler = fn;
|
|
684
745
|
return this;
|
|
685
746
|
}
|
|
747
|
+
useDataSource(plugin, config2) {
|
|
748
|
+
this.dataSourceEntries.push({ plugin, config: config2 });
|
|
749
|
+
return this;
|
|
750
|
+
}
|
|
686
751
|
ui(template) {
|
|
687
752
|
this.template = template;
|
|
688
753
|
return this.build();
|
|
689
754
|
}
|
|
755
|
+
uiFile(path) {
|
|
756
|
+
const fs = __require("fs");
|
|
757
|
+
this.template = fs.readFileSync(path, "utf-8").trim();
|
|
758
|
+
return this.build();
|
|
759
|
+
}
|
|
690
760
|
build() {
|
|
691
761
|
const stateKeys = this.initialState !== null && typeof this.initialState === "object" ? Object.keys(this.initialState) : [];
|
|
692
762
|
const definition = {
|
|
@@ -697,6 +767,7 @@ class HypenAppBuilder {
|
|
|
697
767
|
version: this.options.version,
|
|
698
768
|
initialState: this.initialState,
|
|
699
769
|
template: this.template,
|
|
770
|
+
dataSources: this.dataSourceEntries.length > 0 ? this.dataSourceEntries : undefined,
|
|
700
771
|
handlers: {
|
|
701
772
|
onCreated: this.createdHandler,
|
|
702
773
|
onAction: this.actionHandlers,
|
|
@@ -758,6 +829,8 @@ class HypenModuleInstance {
|
|
|
758
829
|
router;
|
|
759
830
|
globalContext;
|
|
760
831
|
stateChangeCallbacks = [];
|
|
832
|
+
dataSourceManager;
|
|
833
|
+
dataSourceAccessor = {};
|
|
761
834
|
constructor(engine, definition, router, globalContext) {
|
|
762
835
|
this.engine = engine;
|
|
763
836
|
this.definition = definition;
|
|
@@ -787,7 +860,8 @@ class HypenModuleInstance {
|
|
|
787
860
|
const result = await this.executeAction(actionName, handler, {
|
|
788
861
|
action: actionCtx,
|
|
789
862
|
state: this.state,
|
|
790
|
-
context
|
|
863
|
+
context,
|
|
864
|
+
dataSources: this.dataSourceAccessor
|
|
791
865
|
});
|
|
792
866
|
if (!result.ok) {
|
|
793
867
|
const shouldRethrow = await this.handleError(result.error, { actionName });
|
|
@@ -830,8 +904,9 @@ class HypenModuleInstance {
|
|
|
830
904
|
on: (event, handler) => ctx.on(event, handler),
|
|
831
905
|
router: this.router
|
|
832
906
|
};
|
|
833
|
-
|
|
834
|
-
|
|
907
|
+
const ctxRecord = ctx;
|
|
908
|
+
if (ctxRecord.__hypenEngine) {
|
|
909
|
+
api.__hypenEngine = ctxRecord.__hypenEngine;
|
|
835
910
|
}
|
|
836
911
|
return api;
|
|
837
912
|
}
|
|
@@ -878,6 +953,25 @@ class HypenModuleInstance {
|
|
|
878
953
|
return false;
|
|
879
954
|
}
|
|
880
955
|
async callCreatedHandler() {
|
|
956
|
+
if (this.definition.dataSources?.length) {
|
|
957
|
+
const dsEngine = this.engine;
|
|
958
|
+
this.dataSourceManager = new DataSourceManager(dsEngine);
|
|
959
|
+
for (const { plugin, config: config2 } of this.definition.dataSources) {
|
|
960
|
+
try {
|
|
961
|
+
await this.dataSourceManager.use(plugin, config2);
|
|
962
|
+
this.dataSourceAccessor[plugin.name] = new Proxy(plugin, {
|
|
963
|
+
get(target, prop) {
|
|
964
|
+
if (typeof prop === "string" && !(prop in target)) {
|
|
965
|
+
return (...args) => target.call(prop, ...args);
|
|
966
|
+
}
|
|
967
|
+
return target[prop];
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
} catch (e) {
|
|
971
|
+
log2.error(`Failed to connect data source "${plugin.name}":`, e);
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
}
|
|
881
975
|
if (this.definition.handlers.onCreated) {
|
|
882
976
|
const context = this.globalContext ? this.createGlobalContextAPI() : undefined;
|
|
883
977
|
try {
|
|
@@ -897,6 +991,15 @@ class HypenModuleInstance {
|
|
|
897
991
|
async destroy() {
|
|
898
992
|
if (this.isDestroyed)
|
|
899
993
|
return;
|
|
994
|
+
if (this.dataSourceManager) {
|
|
995
|
+
try {
|
|
996
|
+
await this.dataSourceManager.disconnectAll();
|
|
997
|
+
} catch (e) {
|
|
998
|
+
log2.error("Error disconnecting data sources:", e);
|
|
999
|
+
}
|
|
1000
|
+
this.dataSourceManager = undefined;
|
|
1001
|
+
this.dataSourceAccessor = {};
|
|
1002
|
+
}
|
|
900
1003
|
if (this.definition.handlers.onDestroyed) {
|
|
901
1004
|
try {
|
|
902
1005
|
await this.definition.handlers.onDestroyed(this.state);
|
|
@@ -1059,6 +1162,8 @@ async function discoverComponents(baseDir, options = {}) {
|
|
|
1059
1162
|
const module = moduleExport.default;
|
|
1060
1163
|
if (module && typeof module === "object" && module.template) {
|
|
1061
1164
|
addSingleFileComponent(baseName, modulePath, module.template);
|
|
1165
|
+
} else if (module && typeof module === "object") {
|
|
1166
|
+
frameworkLoggers.discovery.warn(`Skipping "${entry.name}": has .ui() pattern but module has no .template property. Did you forget to call .ui(hypen\`...\`)?`);
|
|
1062
1167
|
}
|
|
1063
1168
|
} catch (e) {
|
|
1064
1169
|
log3(`Failed to import potential single-file component: ${entry.name}`, e);
|
|
@@ -1226,4 +1331,4 @@ export {
|
|
|
1226
1331
|
discoverComponents
|
|
1227
1332
|
};
|
|
1228
1333
|
|
|
1229
|
-
//# debugId=
|
|
1334
|
+
//# debugId=D8CFBCA4D5F7D1BB64756E2164756E21
|