@hexium-softworks/inputservice 1.0.1

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,14 @@
1
+ {
2
+ "name": "inputservice",
3
+ "globIgnorePaths": [
4
+ "**/.package-lock.json",
5
+ "**/.pnpm",
6
+ "**/.pnpm-workspace-state-v1.json",
7
+ "**/.modules.yaml",
8
+ "**/.ignored",
9
+ "**/.ignored_*"
10
+ ],
11
+ "tree": {
12
+ "$path": "src"
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@hexium-softworks/inputservice",
3
+ "version": "1.0.1",
4
+ "description": "Game-agnostic IAS wrapper",
5
+ "keywords": [
6
+ "Roblox",
7
+ "Nevermore",
8
+ "Luau",
9
+ "Input"
10
+ ],
11
+ "license": "MIT",
12
+ "files": [
13
+ "src",
14
+ "default.project.json",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "preinstall": "npx only-allow pnpm",
19
+ "build": "echo \"No build step required; publishing Roblox/Luau source files.\""
20
+ },
21
+ "contributors": [
22
+ "HexedEthan"
23
+ ],
24
+ "dependencies": {
25
+ "@quenty/loader": "10.11.0",
26
+ "@quenty/maid": "^3.12.0",
27
+ "@quenty/servicebag": "^11.21.0",
28
+ "@quenty/signal": "^7.13.2",
29
+ "@quenty/table": "^3.9.2"
30
+ },
31
+ "devDependencies": {
32
+ "@quenty/nevermore-test-runner": "1.4.0",
33
+ "@quentystudios/jest-lua": "3.10.0-quenty.2"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,354 @@
1
+ --!strict
2
+ --[=[
3
+ Client-side helpers for Roblox Input Action System contexts.
4
+
5
+ Client mutations are local convenience only. They are non-authoritative and
6
+ are not replicated to the server through this package.
7
+
8
+ @class InputServiceClient
9
+ ]=]
10
+
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
14
+
15
+ local Maid = require("Maid")
16
+ local ServiceBag = require("ServiceBag")
17
+ local Signal = require("Signal")
18
+
19
+ local InputServiceConstants = require("InputServiceConstants")
20
+ local InputServiceInstanceUtils = require("InputServiceInstanceUtils")
21
+ local InputServiceTypes = require("InputServiceTypes")
22
+
23
+ type ServiceBag = ServiceBag.ServiceBag
24
+ type InputContextConfig = InputServiceTypes.InputContextConfig
25
+ type InputActionConfig = InputServiceTypes.InputActionConfig
26
+ type InputBindingConfig = InputServiceTypes.InputBindingConfig
27
+ type InputActionState = InputServiceTypes.InputActionState
28
+ type ActionSignalBundle = InputServiceTypes.ActionSignalBundle
29
+
30
+ local InputServiceClient = {}
31
+ InputServiceClient.ServiceName = "InputServiceClient"
32
+
33
+ export type InputServiceClient = typeof(setmetatable(
34
+ {} :: {
35
+ _serviceBag: ServiceBag,
36
+ _maid: Maid.Maid,
37
+ _rootFolder: Folder?,
38
+ _actionSignals: { [InputAction]: ActionSignalBundle },
39
+ },
40
+ {} :: typeof({ __index = InputServiceClient })
41
+ ))
42
+
43
+ local function findRootFolder(): Folder?
44
+ local child = ReplicatedStorage:FindFirstChild(InputServiceConstants.ROOT_FOLDER_NAME)
45
+ if child == nil then
46
+ return nil
47
+ end
48
+
49
+ assert(
50
+ child:IsA(InputServiceConstants.ROOT_FOLDER_CLASS_NAME),
51
+ string.format("%s must be a %s", child:GetFullName(), InputServiceConstants.ROOT_FOLDER_CLASS_NAME)
52
+ )
53
+
54
+ return child :: Folder
55
+ end
56
+
57
+ local function createActionSignalBundle(action: InputAction): ActionSignalBundle
58
+ local maid = Maid.new()
59
+
60
+ local bundle = {
61
+ Pressed = Signal.new(),
62
+ Released = Signal.new(),
63
+ StateChanged = Signal.new(),
64
+ EnabledChanged = Signal.new(),
65
+ PreferredBindingChanged = Signal.new(),
66
+ } :: any
67
+
68
+ maid:GiveTask(bundle.Pressed)
69
+ maid:GiveTask(bundle.Released)
70
+ maid:GiveTask(bundle.StateChanged)
71
+ maid:GiveTask(bundle.EnabledChanged)
72
+ maid:GiveTask(bundle.PreferredBindingChanged)
73
+
74
+ maid:GiveTask(action.Pressed:Connect(function()
75
+ bundle.Pressed:Fire()
76
+ end))
77
+
78
+ maid:GiveTask(action.Released:Connect(function()
79
+ bundle.Released:Fire()
80
+ end))
81
+
82
+ maid:GiveTask(action.StateChanged:Connect(function(value: any)
83
+ bundle.StateChanged:Fire(value)
84
+ end))
85
+
86
+ maid:GiveTask(action:GetPropertyChangedSignal("Enabled"):Connect(function()
87
+ bundle.EnabledChanged:Fire(action.Enabled)
88
+ end))
89
+
90
+ maid:GiveTask(action:GetPropertyChangedSignal("PreferredBinding"):Connect(function()
91
+ bundle.PreferredBindingChanged:Fire(action.PreferredBinding)
92
+ end))
93
+
94
+ function bundle:Destroy()
95
+ maid:DoCleaning()
96
+ end
97
+
98
+ return bundle :: ActionSignalBundle
99
+ end
100
+
101
+ function InputServiceClient.Init(self: InputServiceClient, serviceBag: ServiceBag): ()
102
+ assert(not self._serviceBag, "Already initialized")
103
+ self._serviceBag = assert(serviceBag, "No serviceBag")
104
+ self._maid = Maid.new()
105
+ self._rootFolder = findRootFolder()
106
+ self._actionSignals = {}
107
+ end
108
+
109
+ function InputServiceClient.Start(_self: InputServiceClient): () end
110
+
111
+ function InputServiceClient._getRootFolder(self: InputServiceClient, createIfMissing: boolean): Folder?
112
+ local rootFolder = self._rootFolder
113
+ if rootFolder and rootFolder.Parent then
114
+ return rootFolder
115
+ end
116
+
117
+ rootFolder = findRootFolder()
118
+ if rootFolder then
119
+ self._rootFolder = rootFolder
120
+ return rootFolder
121
+ end
122
+
123
+ if not createIfMissing then
124
+ return nil
125
+ end
126
+
127
+ self._rootFolder = InputServiceInstanceUtils.getOrCreateRootFolder(ReplicatedStorage)
128
+ return self._rootFolder
129
+ end
130
+
131
+ function InputServiceClient.GetRootFolder(self: InputServiceClient): Folder
132
+ local rootFolder = self:_getRootFolder(true)
133
+ assert(rootFolder, "Input root folder is not available")
134
+
135
+ return rootFolder
136
+ end
137
+
138
+ function InputServiceClient.GetContext(self: InputServiceClient, contextName: string): InputContext?
139
+ assert(typeof(contextName) == "string", "Context name must be a string")
140
+
141
+ local rootFolder = self:_getRootFolder(false)
142
+ if not rootFolder then
143
+ return nil
144
+ end
145
+
146
+ return InputServiceInstanceUtils.getContext(rootFolder, contextName)
147
+ end
148
+
149
+ function InputServiceClient.WaitForContext(
150
+ self: InputServiceClient,
151
+ contextName: string,
152
+ timeoutSeconds: number?
153
+ ): InputContext?
154
+ assert(typeof(contextName) == "string", "Context name must be a string")
155
+ if timeoutSeconds ~= nil then
156
+ assert(typeof(timeoutSeconds) == "number", "Timeout must be a number")
157
+ end
158
+
159
+ local context = self:GetContext(contextName)
160
+ if context then
161
+ return context
162
+ end
163
+
164
+ local rootFolder = self:_getRootFolder(false)
165
+ if not rootFolder then
166
+ local child = ReplicatedStorage:WaitForChild(InputServiceConstants.ROOT_FOLDER_NAME, timeoutSeconds)
167
+ if child == nil then
168
+ return nil
169
+ end
170
+
171
+ assert(
172
+ child:IsA(InputServiceConstants.ROOT_FOLDER_CLASS_NAME),
173
+ string.format("%s must be a %s", child:GetFullName(), InputServiceConstants.ROOT_FOLDER_CLASS_NAME)
174
+ )
175
+
176
+ rootFolder = child :: Folder
177
+ self._rootFolder = rootFolder
178
+ end
179
+
180
+ local child = rootFolder:WaitForChild(contextName, timeoutSeconds)
181
+ if child == nil then
182
+ return nil
183
+ end
184
+
185
+ assert(child:IsA("InputContext"), string.format("%s must be an InputContext", child:GetFullName()))
186
+ return child :: InputContext
187
+ end
188
+
189
+ function InputServiceClient.DefineLocalContext(self: InputServiceClient, config: InputContextConfig): InputContext
190
+ return InputServiceInstanceUtils.upsertContext(self:GetRootFolder(), config)
191
+ end
192
+
193
+ function InputServiceClient.ConfigureAction(
194
+ self: InputServiceClient,
195
+ contextName: string,
196
+ actionConfig: InputActionConfig
197
+ ): InputAction
198
+ local context = self:GetContext(contextName)
199
+ assert(context, string.format("InputContext %q does not exist", contextName))
200
+
201
+ return InputServiceInstanceUtils.upsertAction(context, actionConfig)
202
+ end
203
+
204
+ function InputServiceClient.ConfigureBinding(
205
+ self: InputServiceClient,
206
+ contextName: string,
207
+ actionName: string,
208
+ bindingConfig: InputBindingConfig
209
+ ): InputBinding
210
+ local action = self:GetAction(contextName, actionName)
211
+ assert(action, string.format("InputAction %q.%q does not exist", contextName, actionName))
212
+
213
+ return InputServiceInstanceUtils.upsertBinding(action, bindingConfig)
214
+ end
215
+
216
+ function InputServiceClient.GetAction(self: InputServiceClient, contextName: string, actionName: string): InputAction?
217
+ assert(typeof(actionName) == "string", "Action name must be a string")
218
+
219
+ local context = self:GetContext(contextName)
220
+ if not context then
221
+ return nil
222
+ end
223
+
224
+ return InputServiceInstanceUtils.getAction(context, actionName)
225
+ end
226
+
227
+ function InputServiceClient.GetBinding(
228
+ self: InputServiceClient,
229
+ contextName: string,
230
+ actionName: string,
231
+ bindingName: string
232
+ ): InputBinding?
233
+ assert(typeof(bindingName) == "string", "Binding name must be a string")
234
+
235
+ local action = self:GetAction(contextName, actionName)
236
+ if not action then
237
+ return nil
238
+ end
239
+
240
+ return InputServiceInstanceUtils.getBinding(action, bindingName)
241
+ end
242
+
243
+ function InputServiceClient.GetActionSignals(
244
+ self: InputServiceClient,
245
+ contextName: string,
246
+ actionName: string
247
+ ): ActionSignalBundle
248
+ local action = self:GetAction(contextName, actionName)
249
+ assert(action, string.format("InputAction %q.%q does not exist", contextName, actionName))
250
+
251
+ local existing = self._actionSignals[action]
252
+ if existing then
253
+ return existing
254
+ end
255
+
256
+ local bundle = createActionSignalBundle(action)
257
+ self._actionSignals[action] = bundle
258
+ self._maid:GiveTask(bundle)
259
+ self._maid:GiveTask(action.Destroying:Connect(function()
260
+ self._actionSignals[action] = nil
261
+ bundle:Destroy()
262
+ end))
263
+
264
+ return bundle
265
+ end
266
+
267
+ function InputServiceClient.BindPressed(
268
+ self: InputServiceClient,
269
+ contextName: string,
270
+ actionName: string,
271
+ callback: () -> ()
272
+ ): any
273
+ assert(typeof(callback) == "function", "Callback must be a function")
274
+
275
+ return self:GetActionSignals(contextName, actionName).Pressed:Connect(callback)
276
+ end
277
+
278
+ function InputServiceClient.BindReleased(
279
+ self: InputServiceClient,
280
+ contextName: string,
281
+ actionName: string,
282
+ callback: () -> ()
283
+ ): any
284
+ assert(typeof(callback) == "function", "Callback must be a function")
285
+
286
+ return self:GetActionSignals(contextName, actionName).Released:Connect(callback)
287
+ end
288
+
289
+ function InputServiceClient.BindStateChanged(
290
+ self: InputServiceClient,
291
+ contextName: string,
292
+ actionName: string,
293
+ callback: (InputActionState) -> ()
294
+ ): any
295
+ assert(typeof(callback) == "function", "Callback must be a function")
296
+
297
+ return self:GetActionSignals(contextName, actionName).StateChanged:Connect(callback)
298
+ end
299
+
300
+ function InputServiceClient.SetContextEnabled(self: InputServiceClient, contextName: string, enabled: boolean): ()
301
+ assert(typeof(enabled) == "boolean", "Enabled must be a boolean")
302
+
303
+ local context = self:GetContext(contextName)
304
+ assert(context, string.format("InputContext %q does not exist", contextName))
305
+
306
+ context.Enabled = enabled
307
+ end
308
+
309
+ function InputServiceClient.SetActionEnabled(
310
+ self: InputServiceClient,
311
+ contextName: string,
312
+ actionName: string,
313
+ enabled: boolean
314
+ ): ()
315
+ assert(typeof(enabled) == "boolean", "Enabled must be a boolean")
316
+
317
+ local action = self:GetAction(contextName, actionName)
318
+ assert(action, string.format("InputAction %q.%q does not exist", contextName, actionName))
319
+
320
+ action.Enabled = enabled
321
+ end
322
+
323
+ function InputServiceClient.FireBinding(
324
+ self: InputServiceClient,
325
+ contextName: string,
326
+ actionName: string,
327
+ bindingName: string,
328
+ state: InputActionState
329
+ ): ()
330
+ local binding = self:GetBinding(contextName, actionName, bindingName)
331
+ assert(binding, string.format("InputBinding %q.%q.%q does not exist", contextName, actionName, bindingName))
332
+
333
+ local ok, err = pcall(function()
334
+ binding:Fire(state)
335
+ end)
336
+
337
+ assert(
338
+ ok,
339
+ string.format(
340
+ "Failed to fire scriptable InputBinding %q.%q.%q: %s",
341
+ contextName,
342
+ actionName,
343
+ bindingName,
344
+ tostring(err)
345
+ )
346
+ )
347
+ end
348
+
349
+ function InputServiceClient.Destroy(self: InputServiceClient): ()
350
+ self._maid:DoCleaning()
351
+ table.clear(self._actionSignals)
352
+ end
353
+
354
+ return InputServiceClient
@@ -0,0 +1,71 @@
1
+ --!strict
2
+ --[=[
3
+ @class InputServiceClient.spec
4
+ ]=]
5
+
6
+ local require = require(script.Parent.loader).load(script)
7
+
8
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
9
+
10
+ local InputServiceClient = require("InputServiceClient")
11
+ local InputServiceConstants = require("InputServiceConstants")
12
+ local Jest = require("Jest")
13
+
14
+ local describe = Jest.Globals.describe
15
+ local expect = Jest.Globals.expect
16
+ local it = Jest.Globals.it
17
+
18
+ local function canCreateInputActionInstances(): boolean
19
+ local ok, instance = pcall(Instance.new, InputServiceConstants.CONTEXT_CLASS_NAME, nil)
20
+ if ok and typeof(instance) == "Instance" then
21
+ instance:Destroy()
22
+ return true
23
+ end
24
+
25
+ return false
26
+ end
27
+
28
+ local function newClient()
29
+ local service = setmetatable({}, { __index = InputServiceClient }) :: any
30
+ service:Init({} :: any)
31
+ return service
32
+ end
33
+
34
+ describe("InputServiceClient", function()
35
+ it("guards unsupported runtimes", function()
36
+ expect(typeof(canCreateInputActionInstances())).toEqual("boolean")
37
+ end)
38
+
39
+ if canCreateInputActionInstances() then
40
+ it("caches action signals and disconnects them on destroy", function()
41
+ local existingRoot = ReplicatedStorage:FindFirstChild(InputServiceConstants.ROOT_FOLDER_NAME)
42
+ if existingRoot then
43
+ existingRoot:Destroy()
44
+ end
45
+
46
+ local service = newClient()
47
+ service:DefineLocalContext({
48
+ Name = "SpecContext",
49
+ Actions = {
50
+ { Name = "SpecAction" },
51
+ },
52
+ })
53
+
54
+ local firstSignals = service:GetActionSignals("SpecContext", "SpecAction")
55
+ local secondSignals = service:GetActionSignals("SpecContext", "SpecAction")
56
+ local connection = service:BindPressed("SpecContext", "SpecAction", function() end)
57
+
58
+ expect(firstSignals == secondSignals).toEqual(true)
59
+ expect(connection:IsConnected()).toEqual(true)
60
+
61
+ service:Destroy()
62
+
63
+ expect(connection:IsConnected()).toEqual(false)
64
+
65
+ local root = ReplicatedStorage:FindFirstChild(InputServiceConstants.ROOT_FOLDER_NAME)
66
+ if root then
67
+ root:Destroy()
68
+ end
69
+ end)
70
+ end
71
+ end)
@@ -0,0 +1,186 @@
1
+ --!strict
2
+ --[=[
3
+ Server-owned registry for Roblox Input Action System contexts.
4
+
5
+ This service intentionally exposes no remotes. Client-side input state is
6
+ only intent and must never be treated as gameplay authority by the server.
7
+
8
+ @class InputService
9
+ ]=]
10
+
11
+ local require = require(script.Parent.loader).load(script)
12
+
13
+ local ReplicatedStorage = game:GetService("ReplicatedStorage")
14
+
15
+ local Maid = require("Maid")
16
+ local ServiceBag = require("ServiceBag")
17
+
18
+ local InputServiceInstanceUtils = require("InputServiceInstanceUtils")
19
+ local InputServiceTypes = require("InputServiceTypes")
20
+
21
+ type ServiceBag = ServiceBag.ServiceBag
22
+ type InputContextConfig = InputServiceTypes.InputContextConfig
23
+ type InputActionConfig = InputServiceTypes.InputActionConfig
24
+ type InputBindingConfig = InputServiceTypes.InputBindingConfig
25
+
26
+ local InputService = {}
27
+ InputService.ServiceName = "InputService"
28
+
29
+ export type InputService = typeof(setmetatable(
30
+ {} :: {
31
+ _serviceBag: ServiceBag,
32
+ _maid: Maid.Maid,
33
+ _rootFolder: Folder,
34
+ },
35
+ {} :: typeof({ __index = InputService })
36
+ ))
37
+
38
+ function InputService.Init(self: InputService, serviceBag: ServiceBag): ()
39
+ assert(not self._serviceBag, "Already initialized")
40
+ self._serviceBag = assert(serviceBag, "No serviceBag")
41
+ self._maid = Maid.new()
42
+ self._rootFolder = InputServiceInstanceUtils.getOrCreateRootFolder(ReplicatedStorage)
43
+ end
44
+
45
+ function InputService.Start(_self: InputService): () end
46
+
47
+ --[=[
48
+ Returns the shared input root folder in ReplicatedStorage.
49
+
50
+ @return Folder
51
+ ]=]
52
+ function InputService.GetRootFolder(self: InputService): Folder
53
+ return self._rootFolder
54
+ end
55
+
56
+ --[=[
57
+ Registers or updates a server-owned shared input context.
58
+
59
+ @param config InputContextConfig
60
+ @return InputContext
61
+ ]=]
62
+ function InputService.RegisterContext(self: InputService, config: InputContextConfig): InputContext
63
+ return InputServiceInstanceUtils.upsertContext(self._rootFolder, config)
64
+ end
65
+
66
+ --[=[
67
+ Registers or updates multiple server-owned shared input contexts.
68
+
69
+ @param configs { InputContextConfig }
70
+ ]=]
71
+ function InputService.RegisterContexts(self: InputService, configs: { InputContextConfig }): ()
72
+ assert(typeof(configs) == "table", "Configs must be a table")
73
+
74
+ for _, config in configs do
75
+ self:RegisterContext(config)
76
+ end
77
+ end
78
+
79
+ function InputService.GetContext(self: InputService, contextName: string): InputContext?
80
+ assert(typeof(contextName) == "string", "Context name must be a string")
81
+
82
+ return InputServiceInstanceUtils.getContext(self._rootFolder, contextName)
83
+ end
84
+
85
+ function InputService.GetAction(self: InputService, contextName: string, actionName: string): InputAction?
86
+ assert(typeof(actionName) == "string", "Action name must be a string")
87
+
88
+ local context = self:GetContext(contextName)
89
+ if not context then
90
+ return nil
91
+ end
92
+
93
+ return InputServiceInstanceUtils.getAction(context, actionName)
94
+ end
95
+
96
+ function InputService.GetBinding(
97
+ self: InputService,
98
+ contextName: string,
99
+ actionName: string,
100
+ bindingName: string
101
+ ): InputBinding?
102
+ assert(typeof(bindingName) == "string", "Binding name must be a string")
103
+
104
+ local action = self:GetAction(contextName, actionName)
105
+ if not action then
106
+ return nil
107
+ end
108
+
109
+ return InputServiceInstanceUtils.getBinding(action, bindingName)
110
+ end
111
+
112
+ function InputService.ConfigureAction(
113
+ self: InputService,
114
+ contextName: string,
115
+ actionConfig: InputActionConfig
116
+ ): InputAction
117
+ local context = self:GetContext(contextName)
118
+ assert(context, string.format("InputContext %q does not exist", contextName))
119
+
120
+ return InputServiceInstanceUtils.upsertAction(context, actionConfig)
121
+ end
122
+
123
+ function InputService.ConfigureBinding(
124
+ self: InputService,
125
+ contextName: string,
126
+ actionName: string,
127
+ bindingConfig: InputBindingConfig
128
+ ): InputBinding
129
+ local action = self:GetAction(contextName, actionName)
130
+ assert(action, string.format("InputAction %q.%q does not exist", contextName, actionName))
131
+
132
+ return InputServiceInstanceUtils.upsertBinding(action, bindingConfig)
133
+ end
134
+
135
+ function InputService.SetContextEnabled(self: InputService, contextName: string, enabled: boolean): ()
136
+ assert(typeof(enabled) == "boolean", "Enabled must be a boolean")
137
+
138
+ local context = self:GetContext(contextName)
139
+ assert(context, string.format("InputContext %q does not exist", contextName))
140
+
141
+ context.Enabled = enabled
142
+ end
143
+
144
+ function InputService.SetActionEnabled(self: InputService, contextName: string, actionName: string, enabled: boolean): ()
145
+ assert(typeof(enabled) == "boolean", "Enabled must be a boolean")
146
+
147
+ local action = self:GetAction(contextName, actionName)
148
+ assert(action, string.format("InputAction %q.%q does not exist", contextName, actionName))
149
+
150
+ action.Enabled = enabled
151
+ end
152
+
153
+ function InputService.RemoveContext(self: InputService, contextName: string): boolean
154
+ assert(typeof(contextName) == "string", "Context name must be a string")
155
+
156
+ return InputServiceInstanceUtils.removeContext(self._rootFolder, contextName)
157
+ end
158
+
159
+ function InputService.RemoveAction(self: InputService, contextName: string, actionName: string): boolean
160
+ local context = self:GetContext(contextName)
161
+ if not context then
162
+ return false
163
+ end
164
+
165
+ return InputServiceInstanceUtils.removeAction(context, actionName)
166
+ end
167
+
168
+ function InputService.RemoveBinding(
169
+ self: InputService,
170
+ contextName: string,
171
+ actionName: string,
172
+ bindingName: string
173
+ ): boolean
174
+ local action = self:GetAction(contextName, actionName)
175
+ if not action then
176
+ return false
177
+ end
178
+
179
+ return InputServiceInstanceUtils.removeBinding(action, bindingName)
180
+ end
181
+
182
+ function InputService.Destroy(self: InputService): ()
183
+ self._maid:DoCleaning()
184
+ end
185
+
186
+ return InputService