JavaScript library and add-ons for writing Node.js and browser apps connecting to a Pryv.io platform. It follows the Pryv.io app guidelines.
npm install --save pryv, then in your code:
const pryv = require('pryv');<script src="https://api.pryv.com/lib-js/pryv.js"></script>Other distributions available:
- ES6:
https://api.pryv.com/lib-js/pryv-es6.js - Library bundled with Socket.IO and Monitor add-ons:
https://api.pryv.com/lib-js/pryv-socket.io-monitor.js.
- Socket.IO: NPM package, README
- Monitor: NPM package, README
- CMC (Cross-account Messaging & Consent) client helpers: NPM package, README
- Encryption (client-side event encryption/decryption): README
A connection is an authenticated link to a Pryv.io account.
The format of the API endpoint can be found in your platform's service information under the api property. It usually looks like: https://{token}@{hostname}
const apiEndpoint = 'https://ck6bwmcar00041ep87c8ujf90@drtom.pryv.me';
const connection = new pryv.Connection(apiEndpoint);const service = new pryv.Service('https://reg.pryv.me/service/info');
const apiEndpoint = await service.apiEndpointFor(username, token);
const connection = new pryv.Connection(apiEndpoint);Here is an implementation of the Pryv.io authentication process:
<!doctype html>
<html>
<head>
<title>Pryv authentication example</title>
<script src="https://api.pryv.com/lib-js/pryv.js"></script>
</head>
<body>
<span id="pryv-button"></span>
<script>
var connection = null;
var authSettings = {
spanButtonID: 'pryv-button', // id of the <span> that will be replaced by the service-specific button
onStateChange: authStateChanged, // event listener for authentication steps
authRequest: { // See: https://api.pryv.com/reference/#auth-request
requestingAppId: 'lib-js-test',
languageCode: 'fr', // optional (default: 'en')
requestedPermissions: [
{
streamId: 'test',
defaultName: 'test',
level: 'manage'
},
{
streamId: 'location',
defaultName: 'Location',
level: 'read'
}
],
// optional: how the consent screen should present each permission.
// Ids are a stream permission's streamId (or a feature permission's
// feature). Ignored by older cores, which fall back to
// all-or-nothing rather than failing.
consent: {
allowUserChoice: true, // false (the default) = accept all or deny
mandatory: ['test'], // required: cannot be unticked
optIn: ['location'] // offered UNticked; the user opts in
// a permission in neither list is optional and shown pre-selected
},
clientData: {
'app-web-auth:description': {
'type': 'note/txt', 'content': 'This is a consent message.'
}
},
// referer: 'my test with lib-js', // optional string to track registration source
}
};
var serviceInfoUrl = 'https://api.pryv.com/lib-js/examples/service-info.json';
(async function () {
var service = await pryv.Auth.setupAuth(authSettings, serviceInfoUrl);
})();
function authStateChanged(state) { // called each time the authentication state changes
console.log('# Auth state changed:', state);
if (state.id === pryv.Auth.AuthStates.AUTHORIZED) {
connection = new pryv.Connection(state.apiEndpoint);
logToConsole('# Browser succeeded for user ' + connection.apiEndpoint);
}
if (state.id === pryv.Auth.AuthStates.SIGNOUT) {
connection = null;
logToConsole('# Signed out');
}
}
</script>
</body>
</html>For apps registered as OAuth2 clients on a Pryv.io deployment, pryv.OAuth2Client
drives the authorization-code flow (PKCE). The authorization-server endpoints are
discovered from the issuer via RFC 8414. The scope value is a consent-offer
reference (cmc:<offer-name>) registered on your OAuth client — the user grants
a granular permission set resolved from that offer, and may untick individual
permissions on the consent screen.
const client = new pryv.OAuth2Client({
authorizationServer: 'https://host', // Pryv API base URL (issuer)
clientId: 'my-app', // from the app-account registration
redirectUri: 'https://my-app.example/callback',
scope: 'cmc:study-A' // your registered consent-offer reference
});
// On "Login with Pryv":
await client.redirectToAuthorize();
// On the redirect_uri page:
const connection = await client.handleCallback(window.location.search);
// `connection` is a regular pryv.Connection
// Later, to renew the access token:
const renewed = await client.refresh();The PKCE verifier is generated per flow and stored in sessionStorage (pass
storage to override). This flow is browser-oriented; the polling flow below
stays fully supported.
Requirements.
pryv.OAuth2Clientuses the ESM-onlyoauth4webapidependency, sopryvnow requires Node >= 20.19 (or >= 22.12). Consumers who bundlepryvthemselves need anexports-field-aware bundler (webpack 5 / vite / rollup / esbuild); webpack-4 / browserify users should use the prebuiltdist/bundle.
const apiEndpoint = 'https://ck6bwmcar00041ep87c8ujf90@drtom.pryv.me';
const connection = new pryv.Connection(apiEndpoint);
const accessInfo = await connection.accessInfo();const serviceInfoUrl = 'https://reg.pryv.me/service/info';
const appId = 'lib-js-sample';
const service = new pryv.Service(serviceInfoUrl);
const connection = await service.login(username, password, appId);API calls are based on the "batch" call specification: Call batch API reference
const apiCalls = [
{
"method": "streams.create",
"params": { "id": "heart", "name": "Heart" }
},
{
"method": "events.create",
"params": { "time": 1385046854.282, "streamIds": ["heart"], "type": "frequency/bpm", "content": 90 }
},
{
"method": "events.create",
"params": { "time": 1385046854.283, "streamIds": ["heart"], "type": "frequency/bpm", "content": 120 }
}
]
try {
const result = await connection.api(apiCalls)
} catch (e) {
// handle error
}let count = 0;
// the following will be called on each API method result it was provided for
function handleResult(result, apiCall) { console.log('Got result ' + count++ + ': ' + JSON.stringify(result) + ' For call ' + JSON.stringify(apiCall)); }
function progress(percentage) { console.log('Processed: ' + percentage + '%'); }
const apiCalls = [
{
method: 'streams.create',
params: { id: 'heart', name: 'Heart' }
},
{
method: 'events.create',
params: { time: 1385046854.282, streamIds: ['heart'], type: 'frequency/bpm', content: 90 },
handleResult: handleResult
},
{
method: 'events.create',
params: { time: 1385046854.283, streamIds: ['heart'], type: 'frequency/bpm', content: 120 },
handleResult: handleResult
}
]
try {
const result = await connection.api(apiCalls, progress)
} catch (e) {
// handle error
}When events.get will provide a large result set, it is recommended to use a method that streams the result instead of the batch API call.
pryv.Connection.getEventsStreamed() parses the response JSON as soon as data is available and calls the forEachEvent callback for each event object.
The callback is meant to store the events data, as the function does not return the API call result, which could overflow memory in case of JSON deserialization of a very large data set. Instead, the function returns an events count and possibly event deletions count as well as the common metadata.
const now = (new Date()).getTime() / 1000;
const queryParams = { fromTime: 0, toTime: now, limit: 10000};
const events = [];
function forEachEvent(event) {
events.push(event);
}
try {
const result = await connection.getEventsStreamed(queryParams, forEachEvent);
} catch (e) {
// handle error
}result:
{
eventsCount: 10000,
meta:
{
apiVersion: '1.4.26',
serverTime: 1580728336.864,
serial: '2019061301'
}
}const now = (new Date()).getTime() / 1000;
const queryParams = { fromTime: 0, toTime: now, includeDeletions: true, modifiedSince: 0};
const events = [];
function forEachEvent(event) {
events.push(event);
// events with `deleted` or/and `trashed` properties can be tracked here
}
try {
const result = await connection.getEventsStreamed(queryParams, forEachEvent);
} catch (e) {
// handle error
}result:
{
eventDeletionsCount: 150,
eventsCount: 10000,
meta:
{
apiVersion: '1.4.26',
serverTime: 1580728336.864,
serial: '2019061301'
}
}You can create an event with an attachment in a single API call.
const filePath = './test/my_image.png';
const result = await connection.createEventWithFile({
type: 'picture/attached',
streamIds: ['data']
}, filePath);Or from a Buffer:
const filePath = './test/my_image.png';
const bufferData = fs.readFileSync(filePath);
const result = await connection.createEventWithFileFromBuffer({
type: 'picture/attached',
streamIds: ['data']
}, bufferData, 'my_image.png' /* ← filename */);From an <input>:
<input type="file" id="file-upload"><button onClick='uploadFile()'>Save Value</button>
<script>
var formData = new FormData();
formData.append('file0', document.getElementById('create-file').files[0]) ;
connection.createEventWithFormData({
type: 'file/attached',
streamIds: ['test']
}, formData).then(function (res, err) {
// handle result
});
</script>Programmatically created content:
var formData = new FormData();
var blob = new Blob(['Hello'], { type: "text/txt" });
formData.append("file", blob);
connect.createEventWithFormData({
type: 'file/attached',
streamIds: ['data']
}, formData).then(function (res, err) {
// handle result
});
// Alternative with a filename
connect.createEventWithFileFromBuffer({
type: 'file/attached',
streamIds: ['data']
}, blob /* ← here we can directly use the blob*/, 'filename.txt').then(function (res, err) {
// handle result
});function generateSeries() {
const series = [];
for (let t = 0; t < 100000, t++) { // t will be the deltaTime in seconds
series.push([t, Math.sin(t/1000)]);
}
return series;
}
const pointsA = generateSeries();
const pointsB = generateSeries();
function postHFData(points) { // must return a Promise
return async function (result) { // will be called each time an HF event is created
return await connection.addPointsToHFEvent(result.event.id, ['deltaTime', 'value'], points);
}
}
const apiCalls = [
{
method: 'streams.create',
params: { id: 'signal1', name: 'Signal1' }
},
{
method: 'streams.create',
params: { id: 'signal2', name: 'Signal2' }
},
{
method: 'events.create',
params: { streamIds: ['signal1'], type: 'series:frequency/bpm' },
handleResult: postHFData(pointsA)
},
{
method: 'events.create',
params: { streamIds: ['signal2'], type: 'series:frequency/bpm' },
handleResult: postHFData(pointsB)
}
];
try {
const result = await connection.api(apiCalls);
} catch (e) {
// handle error
}Each Pryv.io platform is considered a "service"; for example Pryv Lab, which is deployed on the pryv.me domain. It is described by a service information settings object (see the service info API reference).
pryv.Service exposes tools to interact with Pryv.io at the "platform" level.
const service = new pryv.Service('https://reg.pryv.me/service/info');Service information properties can be overridden, which can be useful to test new designs on production platforms.
const serviceInfoUrl = 'https://reg.pryv.me/service/info';
const overrides = {
name: 'Pryv Lab 2',
assets: {
definitions: 'https://pryv.github.io/assets-pryv.me/index.json'
}
}
const service = new pryv.Service(serviceInfoUrl, overrides);service.info()returns the service information in a Promise// get the name of the platform const serviceName = await service.info().name
service.infoSync()returns the cached service info; requiresservice.info()to be called beforehandservice.apiEndpointFor(username, token)returns the corresponding API endpoint for the provided credentials (tokenis optional)
A single web app might need to run on different Pryv.io platforms (this is the case of most Pryv.io example apps).
The Pryv.io platform can be specified by passing the service information URL in a query parameter pryvServiceInfoUrl (as per the Pryv app guidelines), which can be extracted with pryv.Browser.serviceInfoFromUrl().
For example: https://api.pryv.com/app-web-access/?pryvServiceInfoUrl=https://reg.pryv.me/service/info
let defaultServiceInfoUrl = 'https://reg.pryv.me/service/info';
// if present, override serviceInfoURL from query param `pryvServiceInfoUrl`
serviceInfoUrl = pryv.Browser.serviceInfoFromUrl() || defaultServiceInfoUrl;
(async function () {
var service = await pryv.Auth.setupAuth(authSettings, serviceInfoUrl, serviceCustomizations);
})();To customize visual assets, please refer to the pryv.me assets repository. For example, see how to customize the sign-in button.
(await service.assets()).setAllDefaults() loads the css and favicon properties of assets definitions:
(async function () {
const service = await pryv.Auth.setupAuth(authSettings, serviceInfoUrl);
(await service.assets()).setAllDefaults(); // will load the default favicon and CSS for this platform
})();You can customize the authentication process (API reference) at different levels:
- Using a custom login button
- Using a custom UI, including the flow of app-web-user-account
You will need to implement a class that instanciates an AuthController object and implements a few methods. We will go through this guide using the Browser's default login button provided with this library as example.
You should provide auth settings (see obtaining a pryv.Connection) and an instance of pryv.Service at initialization. As this phase might contain asynchronous calls, we like to split it between the constructor and an async init() function. In particular, you will need to instanciate an AuthController object.
constructor(authSettings, service) {
this.authSettings = authSettings;
this.service = service;
this.serviceInfo = service.infoSync();
}
async init () {
// initialize button visuals
// ...
// set cookie key for authorization data - browser only
this._cookieKey = 'pryv-libjs-' + this.authSettings.authRequest.requestingAppId;
// initialize controller
this.auth = new AuthController(this.authSettings, this.service, this);
await this.auth.init();
}At initialization, the AuthController will attempt to fetch persisted authorization credentials, using LoginButton.getAuthorizationData(). In the browser, we are using a client-side cookie. For other frameworks, use an appropriate secure storage.
getAuthorizationData () {
return Cookies.get(this._cookieKey);
}The authentication process implementation on the frontend can go through the following states:
LOADING: while the visual assets are loadingINITIALIZED: visuals assets are loaded, or when polling concludes with Result: RefusedNEED_SIGNIN: from the response of the auth request through pollingAUTHORIZED: When polling concludes with Result: AcceptedSIGNOUT: with the account menu, when the user confirms "Log out", just before the client-side authorization credentials are deleted. Withmenu: false, on the click itself, before the "Log out?" question: if the user cancels, the controller re-initializes from the stored credentials (LOADINGthenAUTHORIZED)SWITCHING: an account switch started ({ from, to },toisnullwhen the account is chosen in the sign-in popup); followed byAUTHORIZEDfor the new account, or for the previous one when the switch sign-in is refused. Listeners that ignore it see the usualNEED_SIGNINthenAUTHORIZEDsequenceERROR: see message for more information
You will need to provide a function to react depending on the state. The states NEED_SIGNIN and AUTHORIZED carry the same properties as the auth process polling responses. LOADING, INITIALIZED and SIGNOUT only have status. The ERROR state carries a message property.
async onStateChange (state) {
switch (state.status) {
case AuthStates.LOADING:
this.text = getLoadingMessage(this);
break;
case AuthStates.INITIALIZED:
this.text = getInitializedMessage(this, this.serviceInfo.name);
break;
case AuthStates.NEED_SIGNIN:
const loginUrl = state.authUrl || state.url; // .url is deprecated
if (this.authSettings.authRequest.returnURL) { // open on same page (no Popup)
location.href = loginUrl;
return;
} else {
startLoginScreen(this, loginUrl);
}
break;
case AuthStates.AUTHORIZED:
this.text = state.username;
this.saveAuthorizationData({
apiEndpoint: state.apiEndpoint,
username: state.username
});
break;
case AuthStates.SIGNOUT:
// Emitted by auth.signOut() after the user confirmed in showMenu()
// (below); the controller clears the credentials itself.
break;
case AuthStates.ERROR:
this.text = getErrorMessage(this, state.message);
break;
default:
console.log('WARNING Unhandled state for Login: ' + state.status);
}
if (this.loginButtonText) {
this.loginButtonText.innerHTML = this.text;
}
}The button actions should be handled by the AuthController in the following way:
// LoginButton.js
onClick () {
this.auth.handleClick();
}
// Optional: called on a click once signed in. Ask in your own dialog (never
// window.confirm), then log out through the controller. Return false to get
// the SIGNOUT state on the click instead.
showMenu () {
myDialog.open({ onLogout: () => this.auth.signOut() });
return true;
}// AuthController.js
async handleClick () {
if (isAuthorized.call(this)) {
// a button implementing showMenu() opens its menu (logout through auth.signOut())
if (typeof this.loginButton?.showMenu === 'function' && this.loginButton.showMenu() !== false) return;
this.state = { status: AuthStates.SIGNOUT };
} else if (isInitialized.call(this)) {
this.startAuthRequest();
} else if (isNeedSignIn.call(this)) {
// reopen popup
this.state = this.state;
} else {
console.log('Unhandled action in "handleClick()" for status:', this.state.status);
}
}Clicking the default button once signed in opens a small account menu: the signed-in username, the service and the app id, Manage my account (opens the platform's account app in a new tab) and Log out. SIGNOUT is emitted once, when "Log out" is chosen.
authSettings.menu: falserestores the previous flow:SIGNOUTon click, then a plain "Log out?" confirmation (a built-in dialog, no longerwindow.confirm()).authSettings.menu: { hide: ['account', 'info'] }(or{ account: false }) hides entries:'logout','account','switch','info'.authSettings.accountUrlsets the account app root. Otherwise it is the service'saccount(service/info), then the auth page URL of the last sign-in without its trailing/auth; when none is known, "Manage my account" is not shown.- The menu uses the
.pryv-menu*CSS classes, which a service's button stylesheet can override; its texts come from the button messages (MENU_TITLE,LOGOUT,MANAGE_ACCOUNT,APP,CLOSE).
The controller exposes the same actions: auth.signOut(), auth.openAccountApp() and auth.accountUrl(). A custom button may implement showMenu() to show its own menu (return false to fall back to SIGNOUT).
The button remembers the accounts signed in on the app (at most authSettings.maxProfiles, default 5; the least recently used is forgotten first). The default button keeps the active account in its usual cookie (pryv-libjs-<appId>, apiEndpoint + username, removed when no account is active) and the remembered accounts in a second one (pryv-libjs-<appId>-profiles), so a version without account switching never reads the list as a signed-in account. getAuthorizationData() returns both merged (profiles next to the active account); a custom button stores that object as it is.
- The menu lists them under "Use this app for". Choosing one activates it without a sign-in when its access is still valid (checked with
access-info); an access that was revoked (for example by the end of an account delegation) is marked "no longer available" and asked for again through the sign-in popup. - On platforms with account delegation (
features.delegationinservice/info), "Another account..." runs the auth request again withactAs: 'allow': after signing in, the popup asks whom the access is for (the user's own account or an account they control). An access granted for a controlled account is shown askim (via parent), and the menu offers "Switch back to parent". The app receives an access on the controlled account, never the delegate's own credentials;connection.accessInfo().delegationtells it so authoritatively. - "Log out" logs out of the active account and keeps the others (no account is signed in afterwards); "Log out of all accounts", shown when several are remembered, forgets them all.
authSettings.authRequest.actAs:'allow'(the server default: the popup may offer the accounts the user controls),'deny'(never), or a username to preselect. An app that supplies its own fixed accesstokenand lets users switch accounts ends up with the same token value on several accounts; useactAs: 'deny'to avoid it.
A switch that needs no sign-in, and the return to the previous account after a switch that did not complete, end in an AUTHORIZED state without a key: like the sign-in from stored credentials on page load, it carries the stored username and apiEndpoint.
Controller API: auth.switchTo(username) (null: the user's own account), auth.addAccount(), auth.profiles() ([{ username, actingAs?, active, available }]), auth.currentProfile() and auth.signOut({ all: true }).
You must then provide this class as follows:
let service = await pryv.Auth.setupAuth(
authSettings, // See https://github.com/pryv/lib-js#within-a-webpage-with-a-login-button
serviceInfoUrl,
serviceCustomizations,
MyLoginButton,
);You will find a working example here, and try it running there. To run these examples locally, see below.
For a more advanced scenario, you can check the default button implementation in ./src/Browser/LoginButton.js.
There is a possibility that you would like to register the user in another page. You can find an example here, and try it running there. Again, to run these examples locally, see below.
You can find HTML examples in the ./examples directory. You can run them in two ways:
- With backloop.dev, which allows to run local code with a valid SSL certificate (you must have run
just buildbeforehand):then open the desired example page (e.g. https://l.backloop.dev:9443/examples/auth.htmljust serve - As a simple HTML file, passing service information as JSON to avoid CORS issues
Prerequisites: Node.js 16, just
Then:
just setup-dev-envjust installto install node modulesjust buildfor the initial webpack build
Running just with no argument displays the available commands (defined in justfile).
The project is structured as a monorepo with components (a.k.a. workspaces in NPM), each component defining its package.json, tests, etc. in components/:
pryv: the librarypryv-socket.io: Socket.IO add-onpryv-monitor: Monitor add-onpryv-cmc: CMC (Cross-account Messaging & Consent) client helperspryv-encryption: client-side event encryption/decryption add-on
The code follows the Semi-Standard style.
just build[-watch]
to build the library, add-ons and examples into dist/, and the browser tests into test-browser/
just test <component> [...params]
componentis an existing component's name, orallto run tests on all components- Extra parameters at the end are passed on to Mocha (default settings are defined in
.mocharc.jsfiles) - Replace
testwithtest-debug,test-coverfor common presets
By default, tests are run against Pryv Lab with service information URL https://reg.pryv.me/service/info.
To run the tests against another Pryv.io platform, set the TEST_PRYVLIB_SERVICEINFO_URL environment variable; for example:
TEST_PRYVLIB_SERVICEINFO_URL="https://reg.${DOMAIN}/service/info" just test allTo run the tests against in-development API server components (e.g. open-source or Entreprise), set TEST_PRYVLIB_DNSLESS_URL; for example:
TEST_PRYVLIB_DNSLESS_URL="http://l.backloop.dev:3000/ just test allAssuming browser files have been built (see above):
just test-browser
to run the tests in a browser window.
- Update on CDN: After running setup and build scripts, run
npm run gh-pages ${COMMIT_MESSAGE}. If this fails, runnpm run clearto rebuild a freshdist/folder
Assuming browser files are built and everything is up-to-date, including the READMEs and changelog:
just version <version>
to update the version number of the lib and add-ons in lockstep. This only edits the package.json files — commit and tag manually:
git commit -am "<version>"
git tag -a <version> -m "<version>"
git push && git push --tags
Tags are bare version numbers (e.g. 3.6.0, no v prefix); every npm publish must have a matching pushed tag so npm, package.json and GitHub stay cross-checkable.
just publish-npm
to publish the new versions of the lib and add-ons to NPM
just publish-browser
to commit and push the gh-pages branch from dist/, publishing the browser files to be served via CDN on api.pryv.com/lib-js