-
Notifications
You must be signed in to change notification settings - Fork 846
WP JS Data Sync: First Release #28787
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4c714cb
e17cbd6
cca313a
8b9bd61
1bc781e
59997b6
727cf2c
086e95a
1431347
73f8544
440550b
1f9331e
7beadfe
5eea457
05e5cb8
3da72e4
1c70846
fabe052
8c4eb15
fed2b8b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| module.exports = { | ||
| extends: [ require.resolve( 'jetpack-js-tools/eslintrc/typescript' ) ], | ||
| rules: { | ||
| 'jsdoc/check-alignment': 0, | ||
| 'jsdoc/check-examples': 0, | ||
| 'jsdoc/check-indentation': 0, | ||
| 'jsdoc/check-param-names': 0, | ||
| 'jsdoc/check-syntax': 0, | ||
| 'jsdoc/check-tag-names': 0, | ||
| 'jsdoc/check-types': 0, | ||
| 'jsdoc/implements-on-classes': 0, | ||
| 'jsdoc/newline-after-description': 0, | ||
| 'jsdoc/require-description': 0, | ||
| 'jsdoc/require-hyphen-before-param-description': 0, | ||
| 'jsdoc/require-jsdoc': 0, | ||
| 'jsdoc/require-param': 0, | ||
| 'jsdoc/require-param-description': 0, | ||
| 'jsdoc/require-param-name': 0, | ||
| 'jsdoc/require-param-type': 0, | ||
| 'jsdoc/require-returns': 0, | ||
| 'jsdoc/require-returns-check': 0, | ||
| 'jsdoc/require-returns-description': 0, | ||
| 'jsdoc/require-returns-type': 0, | ||
| 'jsdoc/valid-types': 0, | ||
| 'jsdoc/check-values': 0, | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,143 @@ | ||
| # svelte-data-sync-client | ||
| # Data Sync Client for Svelte | ||
|
|
||
| A Svelte.js client for the wp-js-data-sync package | ||
| This package is intended to be used together with the [`@jetpack/packages/wp-js-data-sync`](https://github.com/Automattic/jetpack/blob/trunk/projects/packages/wp-js-data-sync/) package. | ||
|
|
||
| ## How to install svelte-data-sync-client | ||
| The Data Sync Client for Svelte creates Type-safe [Svelte stores](https://svelte.dev/docs#run-time-svelte-store) that automatically sync with WordPress via the REST API and preload values using `wp_localize_script`. | ||
|
|
||
| ### Installation From Git Repo | ||
| ## Usage | ||
|
|
||
| ## Contribute | ||
| ### Step 1: Initialize the Client | ||
|
|
||
| First, you need to initialize the client, for example in `options.ts` file, this is going to create a namespaced factory that you can use to create stores: | ||
|
|
||
| ```ts | ||
| // favorites.ts | ||
| import { initializeClient } from '@automattic/jetpack-svelte-data-sync-client'; | ||
| const client = initializeClient('jetpack_favorites'); | ||
| ``` | ||
|
|
||
| ### Step 2: Setup type safe stores: | ||
|
|
||
| [Zod](https://zod.dev) is used to ensure that the values are properly typed and match the expectations. | ||
|
|
||
| **Important Zod methods**: | ||
| Zod is a flexible library that helps validate values at run-time. | ||
|
|
||
| This gives you a lot of flexibility setting up the types, depending on how they're used and how they should be handled when something goes wrong. | ||
|
|
||
| Here are a couple important Zod methods to know about: | ||
|
|
||
| - [z.optional](https://github.com/colinhacks/zod#optional) - This is used to mark a field as optional, but it will still be validated if it's present. | ||
| - [z.default](https://github.com/colinhacks/zod#default) - This is used to set a default value for a field if it's undefined, but will not override a value if it's already set, even if the type doesn't match. | ||
| - [z.catch](https://github.com/colinhacks/zod#catch) - This is used to catch errors and return a default value if the type doesn't match. | ||
| - [z.passthrough](https://github.com/colinhacks/zod#passthrough) - By default Zod object schemas strip out unrecognized keys during parsing. Using `passthrough` will allow unrecognized keys to be passed through. | ||
|
|
||
| ```ts | ||
| import { z } from 'zod'; | ||
|
|
||
| // favorites.ts | ||
| const favorite_post_schema = z.object({ | ||
| id: z.number(), | ||
| title: z.string(), | ||
| }); | ||
|
|
||
| export const favorites = { | ||
| enabled: client.createAsyncStore('favorite_posts_enabled', z.boolean().catch(false)), | ||
| posts: client.createAsyncStore('favorite_posts', z.array(favorite_post_schema).catch([])), | ||
| }; | ||
| ``` | ||
|
|
||
| That's it, now you can use `favorites.enabled` and `favorites.posts` in your Svelte components. | ||
|
|
||
| #### Step 3: Store Usage | ||
|
|
||
| Use `client.createAsyncStore()` to create an object with two Svelte stores: | ||
|
|
||
| - `store`: Use this as a normal Svelte store. When the value is updated, it will dispatch POST requests to the REST API endpoint. | ||
| - `pending`: In case you need to display a loading state, this store will be `true` while the value is being updated. | ||
|
|
||
| Here's a simple example of how that would work: | ||
|
|
||
| ```svelte | ||
| <script type="ts"> | ||
| import { favorites } from "./favorites.ts"; | ||
| const enabled = favorites.enabled.store; | ||
| const pending = favorites.enabled.pending; | ||
| </script> | ||
|
|
||
| {#if $pending} | ||
| 🌊 I'm updating the value | ||
| {/if} | ||
|
|
||
| <label for="favorite-posts-enabled"> | ||
| <input type="checkbox" bind:checked={$enabled} /> | ||
| Enable | ||
| </label> | ||
| ``` | ||
|
|
||
| #### Interacting with REST API | ||
|
|
||
| Every created Data Sync Client Store will also have an `.endpoints` property that can be used to interact with the REST API endpoints directly if needed (for example, to refresh a value). | ||
|
|
||
| ```ts | ||
| // favorites.ts | ||
| const result = await favorites.enabled.endpoints.GET(); | ||
| const result = await favorites.enabled.endpoints.POST( true );; | ||
| ``` | ||
|
|
||
| Note that the endpoint methods are type-safe too, so you can't pass a value that doesn't match the schema. If you do, errors will be thrown. | ||
|
|
||
| If you need to interact with the REST API endpoints directly, you can use the [API](./src/API.ts) class directly: | ||
|
|
||
| ```ts | ||
| const api = new API(); | ||
| // API Must be initialized with a nonce, otherwise WordPress REST API will return a 403 error. | ||
| api.initialize( 'jetpack_favorites', window.jetpack_favorites.rest_api.nonce ); | ||
|
|
||
| // Send a request to any endpoint: | ||
| const result = await api.request( 'GET', 'foobar', "<endpoint-nonce>"); | ||
| ``` | ||
|
|
||
| To dive in deeper, have a look at [API](./src/API.ts) and [Endpoint](./src/Endpoint.ts) source files. | ||
|
|
||
| #### Putting it all together | ||
|
|
||
| Here's all the boilerplate code to get you started quickly: | ||
|
|
||
| ```ts | ||
| // favorites.ts | ||
| import { z } from 'zod'; | ||
| import { initializeClient } from '@automattic/jetpack-svelte-data-sync-client'; | ||
|
|
||
| const client = initializeClient('jetpack_favorites'); | ||
|
|
||
| const favorite_post_schema = z.object({ | ||
| ID: z.number(), | ||
| post_title: z.string(), | ||
| }); | ||
|
|
||
| export const favorites = { | ||
| enabled: client.createAsyncStore('enabled', z.boolean().catch(false)), | ||
| posts: client.createAsyncStore('posts', z.array(favorite_post_schema), | ||
| }; | ||
| ``` | ||
|
|
||
| And use the stores in Svelte. | ||
|
|
||
| ```svelte | ||
| <div class="posts"> | ||
| <h1>Posts</h1> | ||
| {#each $posts as post} | ||
| <h1>{post.post_title} ({post.ID})</h1> | ||
| {/each} | ||
| </div> | ||
| ``` | ||
|
|
||
| ## Get Help | ||
|
|
||
| ## Security | ||
|
|
||
| Need to report a security vulnerability? Go to [https://automattic.com/security/](https://automattic.com/security/) or directly to our security bug bounty site [https://hackerone.com/automattic](https://hackerone.com/automattic). | ||
|
|
||
| ## License | ||
|
|
||
| svelte-data-sync-client is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt) | ||
|
|
||
| Svelte Data Sync Client is licensed under [GNU General Public License v2 (or later)](./LICENSE.txt) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| Significance: major | ||
| Type: added | ||
|
|
||
| First release |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| /* eslint-disable no-console */ | ||
| import { JSONSchema } from './utils'; | ||
| export type RequestParams = string | JSONSchema; | ||
| export type RequestMethods = 'GET' | 'POST' | 'DELETE'; | ||
| export class API { | ||
| private baseUrl: string; | ||
| private restNonce: string; | ||
|
|
||
| /** | ||
| * The API class must be initialized with | ||
| * the base URL and the REST nonce. | ||
| * | ||
| * @param baseUrl - For example: http://localhost/wp-json/jetpack-favorites | ||
| * @param restNonce - For example: abcdefghij | ||
| */ | ||
| public initialize( baseUrl: string, restNonce: string ) { | ||
| this.baseUrl = baseUrl; | ||
| this.restNonce = restNonce; | ||
| } | ||
|
|
||
| public isInitialized() { | ||
| return !! this.baseUrl && !! this.restNonce; | ||
| } | ||
|
|
||
| /** | ||
| * The API Class should already be initialized with | ||
| * the Base URL (that includes the REST namespace) and the REST nonce. | ||
| * @see initialize | ||
| * | ||
| * So request methods need only the endpoint path, | ||
| * For example: | ||
| * ```js | ||
| * const api = new API(); | ||
| * api.initialize( 'http://localhost/wp-json/jetpack-favorites', 'abcdefghij' ); | ||
| * api.request( 'posts' ); | ||
| * ``` | ||
| * This would make a request to: http://localhost/wp-json/jetpack-favorites/posts | ||
| */ | ||
| public async request( | ||
| partialPathname: string, | ||
| method: RequestMethods = 'GET', | ||
| endpointNonce: string, | ||
| params?: RequestParams | ||
| ) { | ||
| if ( ! this.isInitialized() ) { | ||
| console.error( 'API is not initialized', { | ||
| baseUrl: this.baseUrl, | ||
| restNonce: this.restNonce, | ||
| } ); | ||
| return null; | ||
| } | ||
|
|
||
| const url = `${ this.baseUrl }/${ partialPathname }`; | ||
|
|
||
| const args: RequestInit = { | ||
| method, | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'X-WP-Nonce': this.restNonce, | ||
| 'X-Jetpack-WP-JS-Sync-Nonce': endpointNonce, | ||
| }, | ||
| credentials: 'same-origin', | ||
| body: null, | ||
| }; | ||
|
|
||
| if ( method === 'POST' && params ) { | ||
| args.body = JSON.stringify( { JSON: params } ); | ||
| } | ||
|
|
||
| const result = await fetch( url, args ); | ||
| if ( ! result.ok ) { | ||
| console.error( 'Failed to fetch', url, result ); | ||
| throw new Error( `Failed to "${ method }" to ${ url }. Received ${ result.status }` ); | ||
| } | ||
|
|
||
| let data; | ||
| const text = await result.text(); | ||
| try { | ||
| data = JSON.parse( text ); | ||
| } catch ( e ) { | ||
| console.error( 'Failed to parse the response\n', { url, text, result, error: e } ); | ||
| } | ||
|
|
||
| /** | ||
| * `data.JSON` is used to keep in line with how WP REST API parses request json params | ||
| * It also allows frees up the the endpoint to accept other values in the root of the JSON object | ||
| * if that ever becomes necessary. | ||
| * @see https://developer.wordpress.org/reference/classes/wp_rest_request/parse_json_params/ | ||
| * @see https://github.com/WordPress/wordpress-develop/blob/28f10e4af559c9b4dbbd1768feff0bae575d5e78/src/wp-includes/rest-api/class-wp-rest-request.php#L701 | ||
| */ | ||
| if ( ! data || data.JSON === undefined ) { | ||
| console.error( 'Failed to parse the response\n', { url, text, result } ); | ||
| throw new Error( `Failed to "${ method }" to ${ url }. Received ${ result.status }` ); | ||
| } | ||
|
|
||
| return data.JSON; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not assign
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added a comment:
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Typo - |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { z } from 'zod'; | ||
| import { API, RequestMethods, RequestParams } from './API'; | ||
|
|
||
| /** | ||
| * Every SyncedStore option has its own API Endpoint. | ||
| */ | ||
| export class API_Endpoint< T extends RequestParams > { | ||
| public nonce = ''; | ||
|
|
||
| private endpoint: string; | ||
|
|
||
| constructor( private api: API, private name: string, private schema: z.ZodSchema ) { | ||
| /** | ||
| * Convert underscores to dashes, | ||
| * because all endpoints are kebab-case and options are snake_case. | ||
| * For example, `jetpack_favorites` becomes `jetpack-favorites`. | ||
| * | ||
| * For more information on the shape of the API, | ||
| * @see API.request | ||
| */ | ||
| this.endpoint = this.name.replace( '_', '-' ); | ||
| } | ||
|
|
||
| public async validatedRequest( method: RequestMethods = 'GET', params?: T ): Promise< T > { | ||
| const request = this.api.request( this.endpoint, method, this.nonce, params ); | ||
| return await request.then( data => { | ||
| return this.schema.parse( data ); | ||
| } ); | ||
| } | ||
|
|
||
| /** | ||
| * Class member variables: | ||
| * | ||
| * Variables below are class member variables, instead of class methods, | ||
| * because they need to be bound to the class instance, to make it | ||
| * easier to pass them around as callbacks | ||
| * without losing the `this` context. | ||
| */ | ||
| public GET = async (): Promise< T > => { | ||
| return await this.validatedRequest( 'GET' ); | ||
| }; | ||
|
|
||
| public POST = async ( params: T ): Promise< T > => { | ||
| return await this.validatedRequest( 'POST', params ); | ||
| }; | ||
|
|
||
| public DELETE = async () => { | ||
dilirity marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return await this.validatedRequest( 'DELETE' ); | ||
| }; | ||
|
Comment on lines
+43
to
+49
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are these written as class member variables that contain a function instead of as functions? (i.e.: like GET)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So that they can be run in a callback without losing |
||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.